@packet-net/pdn-aprs 0.0.1
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 +13 -0
- package/LICENSE +661 -0
- package/README.md +192 -0
- package/THIRD-PARTY-NOTICES.md +5 -0
- package/dist/builder.d.ts +232 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +508 -0
- package/dist/builder.js.map +1 -0
- package/dist/bytes.d.ts +19 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +120 -0
- package/dist/bytes.js.map +1 -0
- package/dist/codes.d.ts +13 -0
- package/dist/codes.d.ts.map +1 -0
- package/dist/codes.js +112 -0
- package/dist/codes.js.map +1 -0
- package/dist/decode/context.d.ts +33 -0
- package/dist/decode/context.d.ts.map +1 -0
- package/dist/decode/context.js +65 -0
- package/dist/decode/context.js.map +1 -0
- package/dist/decode/index.d.ts +10 -0
- package/dist/decode/index.d.ts.map +1 -0
- package/dist/decode/index.js +121 -0
- package/dist/decode/index.js.map +1 -0
- package/dist/decode/message.d.ts +5 -0
- package/dist/decode/message.d.ts.map +1 -0
- package/dist/decode/message.js +190 -0
- package/dist/decode/message.js.map +1 -0
- package/dist/decode/other.d.ts +25 -0
- package/dist/decode/other.d.ts.map +1 -0
- package/dist/decode/other.js +326 -0
- package/dist/decode/other.js.map +1 -0
- package/dist/decode/positioned.d.ts +132 -0
- package/dist/decode/positioned.d.ts.map +1 -0
- package/dist/decode/positioned.js +1063 -0
- package/dist/decode/positioned.js.map +1 -0
- package/dist/decode/reports.d.ts +13 -0
- package/dist/decode/reports.d.ts.map +1 -0
- package/dist/decode/reports.js +375 -0
- package/dist/decode/reports.js.map +1 -0
- package/dist/decoder.d.ts +23 -0
- package/dist/decoder.d.ts.map +1 -0
- package/dist/decoder.js +75 -0
- package/dist/decoder.js.map +1 -0
- package/dist/deviceid-data.d.ts +33 -0
- package/dist/deviceid-data.d.ts.map +1 -0
- package/dist/deviceid-data.js +2941 -0
- package/dist/deviceid-data.js.map +1 -0
- package/dist/deviceid.d.ts +12 -0
- package/dist/deviceid.d.ts.map +1 -0
- package/dist/deviceid.js +86 -0
- package/dist/deviceid.js.map +1 -0
- package/dist/diagnostics.d.ts +48 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +65 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/encode/common.d.ts +19 -0
- package/dist/encode/common.d.ts.map +1 -0
- package/dist/encode/common.js +84 -0
- package/dist/encode/common.js.map +1 -0
- package/dist/encode/index.d.ts +35 -0
- package/dist/encode/index.d.ts.map +1 -0
- package/dist/encode/index.js +248 -0
- package/dist/encode/index.js.map +1 -0
- package/dist/encode/other.d.ts +21 -0
- package/dist/encode/other.d.ts.map +1 -0
- package/dist/encode/other.js +253 -0
- package/dist/encode/other.js.map +1 -0
- package/dist/encode/position.d.ts +31 -0
- package/dist/encode/position.d.ts.map +1 -0
- package/dist/encode/position.js +729 -0
- package/dist/encode/position.js.map +1 -0
- package/dist/header.d.ts +42 -0
- package/dist/header.d.ts.map +1 -0
- package/dist/header.js +206 -0
- package/dist/header.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/neutral.d.ts +22 -0
- package/dist/neutral.d.ts.map +1 -0
- package/dist/neutral.js +593 -0
- package/dist/neutral.js.map +1 -0
- package/dist/symbols-data.d.ts +832 -0
- package/dist/symbols-data.d.ts.map +1 -0
- package/dist/symbols-data.js +173 -0
- package/dist/symbols-data.js.map +1 -0
- package/dist/symbols.d.ts +35 -0
- package/dist/symbols.d.ts.map +1 -0
- package/dist/symbols.js +71 -0
- package/dist/symbols.js.map +1 -0
- package/dist/types.d.ts +458 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/package.json +68 -0
- package/src/builder.ts +617 -0
- package/src/bytes.ts +118 -0
- package/src/codes.ts +218 -0
- package/src/decode/context.ts +63 -0
- package/src/decode/index.ts +135 -0
- package/src/decode/message.ts +194 -0
- package/src/decode/other.ts +307 -0
- package/src/decode/positioned.ts +1161 -0
- package/src/decode/reports.ts +378 -0
- package/src/decoder.ts +76 -0
- package/src/deviceid-data.ts +2954 -0
- package/src/deviceid.ts +83 -0
- package/src/diagnostics.ts +86 -0
- package/src/encode/common.ts +87 -0
- package/src/encode/index.ts +280 -0
- package/src/encode/other.ts +247 -0
- package/src/encode/position.ts +656 -0
- package/src/header.ts +221 -0
- package/src/index.ts +57 -0
- package/src/neutral.ts +595 -0
- package/src/symbols-data.ts +181 -0
- package/src/symbols.ts +85 -0
- package/src/types.ts +571 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
// Encoding positions, objects, items and Mic-E reports, with their data extensions, weather and
|
|
2
|
+
// comment elements, in the canonical order: extension, altitude, frequency, comment, base-91
|
|
3
|
+
// telemetry, !DAO! (and for Mic-E, the device suffix last).
|
|
4
|
+
|
|
5
|
+
import { AREA_COLORS, AREA_SHAPES, KNOTS_TO_MPH, METRES_PER_FOOT, MICROWAVE_BASE } from '../decode/positioned.js';
|
|
6
|
+
import type {
|
|
7
|
+
AprsSymbol,
|
|
8
|
+
CommentTelemetry,
|
|
9
|
+
CompressionType,
|
|
10
|
+
ItemReport,
|
|
11
|
+
MicEMessage,
|
|
12
|
+
MicEReport,
|
|
13
|
+
ObjectReport,
|
|
14
|
+
PositionedFields,
|
|
15
|
+
PositionReport,
|
|
16
|
+
VoiceFrequency,
|
|
17
|
+
Weather,
|
|
18
|
+
WeatherReport,
|
|
19
|
+
} from '../types.js';
|
|
20
|
+
import { base91, checkSymbol, freeText, isValidTimestamp, pad, refuse, timestamp7, whole } from './common.js';
|
|
21
|
+
|
|
22
|
+
const EPS = 1e-9;
|
|
23
|
+
|
|
24
|
+
/** Negative, or negative zero: a zero coordinate keeps its hemisphere as the sign of zero. */
|
|
25
|
+
export function isNegative(v: number): boolean {
|
|
26
|
+
return v < 0 || Object.is(v, -0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- coordinates
|
|
30
|
+
|
|
31
|
+
interface Digits {
|
|
32
|
+
degrees: number;
|
|
33
|
+
/** Hundredths of a minute. */
|
|
34
|
+
hundredths: number;
|
|
35
|
+
/** The remainder below a hundredth, in minutes (for a !DAO!). */
|
|
36
|
+
remainder: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Degrees and hundredths of a minute; truncated when a !DAO! or ambiguity follows, else rounded. */
|
|
40
|
+
function digitsOf(value: number, truncate: boolean): Digits {
|
|
41
|
+
const abs = Math.abs(value);
|
|
42
|
+
let degrees = Math.floor(abs + EPS);
|
|
43
|
+
let minutes = (abs - degrees) * 60;
|
|
44
|
+
if (minutes < 0) minutes = 0;
|
|
45
|
+
let hundredths = truncate ? Math.floor(minutes * 100 + EPS) : Math.round(minutes * 100);
|
|
46
|
+
if (hundredths >= 6000) {
|
|
47
|
+
degrees += 1;
|
|
48
|
+
hundredths -= 6000;
|
|
49
|
+
}
|
|
50
|
+
const remainder = Math.max(0, minutes - hundredths / 100);
|
|
51
|
+
return { degrees, hundredths, remainder };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function blank(text: string, positions: readonly number[], ambiguity: number): string {
|
|
55
|
+
const chars = text.split('');
|
|
56
|
+
for (let k = 0; k < ambiguity; k++) chars[positions[positions.length - 1 - k]!] = ' ';
|
|
57
|
+
return chars.join('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `ddmm.hhN/dddmm.hhW$`, with the DAO remainders. */
|
|
61
|
+
function uncompressedPosition(
|
|
62
|
+
f: PositionedFields,
|
|
63
|
+
truncate: boolean,
|
|
64
|
+
): { text: string; latRemainder: number; lonRemainder: number } {
|
|
65
|
+
if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
|
|
66
|
+
if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
|
|
67
|
+
const ambiguity = f.ambiguity ?? 0;
|
|
68
|
+
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
|
|
69
|
+
checkSymbol(f.symbol);
|
|
70
|
+
const lat = digitsOf(f.latitude, truncate || ambiguity > 0);
|
|
71
|
+
const lon = digitsOf(f.longitude, truncate || ambiguity > 0);
|
|
72
|
+
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
|
|
73
|
+
if (lon.degrees > 180 || (lon.degrees === 180 && lon.hundredths > 0)) refuse('longitude is out of range');
|
|
74
|
+
const latMin = pad(Math.floor(lat.hundredths / 100), 2) + '.' + pad(lat.hundredths % 100, 2);
|
|
75
|
+
const lonMin = pad(Math.floor(lon.hundredths / 100), 2) + '.' + pad(lon.hundredths % 100, 2);
|
|
76
|
+
const latText = blank(pad(lat.degrees, 2) + latMin, [2, 3, 5, 6], ambiguity) + (isNegative(f.latitude) ? 'S' : 'N');
|
|
77
|
+
const lonText = blank(pad(lon.degrees, 3) + lonMin, [3, 4, 6, 7], ambiguity) + (isNegative(f.longitude) ? 'W' : 'E');
|
|
78
|
+
return {
|
|
79
|
+
text: latText + f.symbol.table + lonText + f.symbol.code,
|
|
80
|
+
latRemainder: lat.remainder,
|
|
81
|
+
lonRemainder: lon.remainder,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SOURCES = ['other', 'gll', 'gga', 'rmc'];
|
|
86
|
+
const ORIGINS = ['compressed', 'tnc-beacon-text', 'software', 'reserved3', 'kpc3', 'pico', 'other-tracker', 'digipeater-conversion'];
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_COMPRESSION: CompressionType = { fix: 'current', source: 'other', origin: 'software' };
|
|
89
|
+
|
|
90
|
+
function typeByte(c: CompressionType): string {
|
|
91
|
+
const source = SOURCES.indexOf(c.source);
|
|
92
|
+
const origin = ORIGINS.indexOf(c.origin);
|
|
93
|
+
if (source < 0 || origin < 0 || (c.fix !== 'old' && c.fix !== 'current')) refuse('the compression type is not one APRS defines');
|
|
94
|
+
return String.fromCharCode(((c.fix === 'current' ? 1 : 0) << 5) + (source << 3) + origin + 33);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The compressed position; `cs` is the two cs bytes or `undefined` for none. */
|
|
98
|
+
function compressedPosition(f: PositionedFields, cs: string | undefined, compression: CompressionType | undefined): string {
|
|
99
|
+
if (!(f.latitude >= -90 && f.latitude <= 90)) refuse(`latitude ${f.latitude} is out of range`);
|
|
100
|
+
if (!(f.longitude >= -180 && f.longitude <= 180)) refuse(`longitude ${f.longitude} is out of range`);
|
|
101
|
+
if ((f.ambiguity ?? 0) !== 0) refuse('a compressed position cannot be ambiguous');
|
|
102
|
+
checkSymbol(f.symbol);
|
|
103
|
+
let table = f.symbol.table;
|
|
104
|
+
if (table >= '0' && table <= '9') table = String.fromCharCode(table.charCodeAt(0) + 49); // 0-9 -> a-j
|
|
105
|
+
const max = 91 ** 4 - 1;
|
|
106
|
+
const y = Math.min(max, Math.max(0, Math.round(380926 * (90 - f.latitude))));
|
|
107
|
+
const x = Math.min(max, Math.max(0, Math.round(190463 * (180 + f.longitude))));
|
|
108
|
+
const tail = cs === undefined ? ' sT' : cs + typeByte(compression ?? DEFAULT_COMPRESSION);
|
|
109
|
+
return table + base91(y, 4) + base91(x, 4) + f.symbol.code + tail;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- data extensions and comment elements
|
|
113
|
+
|
|
114
|
+
function courseSpeed(course: number | undefined, speed: number | undefined, what = 'course'): string {
|
|
115
|
+
const c = course === undefined ? '...' : pad(whole(course, 0, 360, what), 3);
|
|
116
|
+
const s = speed === undefined ? '...' : pad(whole(speed, 0, 999, 'speed'), 3);
|
|
117
|
+
return `${c}/${s}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function heightChar(h: number): string {
|
|
121
|
+
return String.fromCharCode(whole(h, 0, 78, 'height code') + 48);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function rateChar(r: number): string {
|
|
125
|
+
const v = whole(r, 1, 35, 'beacons per hour');
|
|
126
|
+
return v <= 9 ? String(v) : String.fromCharCode(55 + v);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function altitudeText(feet: number): string {
|
|
130
|
+
if (!Number.isInteger(feet)) refuse('an /A= altitude is whole feet');
|
|
131
|
+
if (feet < 0) {
|
|
132
|
+
if (feet < -99999) refuse('altitude is out of range');
|
|
133
|
+
return `/A=-${pad(-feet, 5)}`;
|
|
134
|
+
}
|
|
135
|
+
if (feet > 999999) refuse('altitude is out of range');
|
|
136
|
+
return `/A=${pad(feet, 6)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function frequencyText(f: VoiceFrequency): string {
|
|
140
|
+
const mhz = f.mhz;
|
|
141
|
+
if (!(mhz >= 0)) refuse('the frequency is negative');
|
|
142
|
+
let whole3: string;
|
|
143
|
+
let fraction: string;
|
|
144
|
+
const resolution = f.tenKhzResolution ? 100 : 1000;
|
|
145
|
+
const scaled = Math.round(mhz * resolution);
|
|
146
|
+
const intPart = Math.floor(scaled / resolution);
|
|
147
|
+
const fracPart = scaled % resolution;
|
|
148
|
+
fraction = pad(fracPart, f.tenKhzResolution ? 2 : 3);
|
|
149
|
+
if (intPart < 1000) {
|
|
150
|
+
whole3 = pad(intPart, 3);
|
|
151
|
+
} else {
|
|
152
|
+
const letter = Object.entries(MICROWAVE_BASE).find(([, base]) => intPart >= base && intPart < base + 100);
|
|
153
|
+
if (!letter) refuse(`${mhz} MHz has no APRS frequency form`);
|
|
154
|
+
whole3 = letter[0] + pad(intPart - letter[1], 2);
|
|
155
|
+
}
|
|
156
|
+
let out = f.tenKhzResolution ? `${whole3}.${fraction} MHz` : `${whole3}.${fraction}MHz`;
|
|
157
|
+
if (f.tone !== undefined) {
|
|
158
|
+
const narrow = f.narrow === true;
|
|
159
|
+
switch (f.tone) {
|
|
160
|
+
case 'off':
|
|
161
|
+
out += narrow ? ' toff' : ' Toff';
|
|
162
|
+
break;
|
|
163
|
+
case 'tone-burst':
|
|
164
|
+
out += narrow ? ' l750' : ' 1750';
|
|
165
|
+
break;
|
|
166
|
+
default: {
|
|
167
|
+
const letter = f.tone === 'tone' ? 'T' : f.tone === 'ctcss' ? 'C' : f.tone === 'dcs' ? 'D' : refuse('unknown tone type');
|
|
168
|
+
if (f.toneValue === undefined) refuse('a tone needs its value');
|
|
169
|
+
out += ' ' + (narrow ? letter.toLowerCase() : letter) + pad(whole(f.toneValue, 0, 999, 'tone'), 3);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} else if (f.narrow) {
|
|
173
|
+
refuse('narrow modulation is written with the tone');
|
|
174
|
+
}
|
|
175
|
+
if (f.offsetKhz !== undefined) {
|
|
176
|
+
if (f.offsetKhz % 10 !== 0) refuse('the offset is in tens of kHz');
|
|
177
|
+
const v = whole(Math.abs(f.offsetKhz) / 10, 0, 999, 'offset');
|
|
178
|
+
out += ` ${f.offsetKhz < 0 ? '-' : '+'}${pad(v, 3)}`;
|
|
179
|
+
}
|
|
180
|
+
if (f.range !== undefined) {
|
|
181
|
+
out += ` R${pad(whole(f.range, 0, 999, 'range'), 2)}${f.rangeKm ? 'k' : 'm'}`;
|
|
182
|
+
} else if (f.rangeKm) {
|
|
183
|
+
refuse('range in km needs a range');
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function telemetryText(t: CommentTelemetry): string {
|
|
189
|
+
if (t.analog.length < 1 || t.analog.length > 5) refuse('comment telemetry has 1-5 analog values');
|
|
190
|
+
if (t.digital !== undefined && t.analog.length !== 5) refuse('comment telemetry bits come after all five analog values');
|
|
191
|
+
const v = (n: number, what: string): string => base91(whole(n, 0, 8280, what), 2);
|
|
192
|
+
let out = '|' + v(t.sequence, 'telemetry sequence');
|
|
193
|
+
for (const a of t.analog) out += v(a, 'telemetry value');
|
|
194
|
+
if (t.digital !== undefined) out += v(t.digital, 'telemetry bits');
|
|
195
|
+
return out + '|';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function daoText(f: PositionedFields, latRemainder: number, lonRemainder: number, applied: boolean): string {
|
|
199
|
+
const dao = f.dao!;
|
|
200
|
+
const datum = dao.datum;
|
|
201
|
+
if (!/^[A-Za-z]$/.test(datum)) refuse('the !DAO! datum is a letter');
|
|
202
|
+
switch (dao.precision) {
|
|
203
|
+
case 'none':
|
|
204
|
+
return `!${datum.toUpperCase()} !`;
|
|
205
|
+
case 'thousandths': {
|
|
206
|
+
if (!applied) return `!${datum.toUpperCase()}00!`;
|
|
207
|
+
const a = Math.min(9, Math.round(latRemainder * 1000));
|
|
208
|
+
const o = Math.min(9, Math.round(lonRemainder * 1000));
|
|
209
|
+
return `!${datum.toUpperCase()}${a}${o}!`;
|
|
210
|
+
}
|
|
211
|
+
case 'base91': {
|
|
212
|
+
if (!applied) return `!${datum.toLowerCase()}!!!`;
|
|
213
|
+
const a = Math.min(90, Math.round(latRemainder * 100 * 91));
|
|
214
|
+
const o = Math.min(90, Math.round(lonRemainder * 100 * 91));
|
|
215
|
+
return `!${datum.toLowerCase()}${String.fromCharCode(a + 33)}${String.fromCharCode(o + 33)}!`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- weather
|
|
221
|
+
|
|
222
|
+
function weatherValue(v: number | undefined, width: number, min: number, max: number, what: string): string {
|
|
223
|
+
if (v === undefined) return '.'.repeat(width);
|
|
224
|
+
const n = whole(v, min, max, what);
|
|
225
|
+
if (n < 0) return '-' + pad(-n, width - 1);
|
|
226
|
+
return pad(n, width);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Weather fields after the wind: gust, temperature, rain, humidity, pressure, and on. */
|
|
230
|
+
function weatherFields(w: Weather, mandatory: boolean): string {
|
|
231
|
+
let out = '';
|
|
232
|
+
if (mandatory || w.windGustMph !== undefined) out += 'g' + weatherValue(w.windGustMph, 3, 0, 999, 'gust');
|
|
233
|
+
if (mandatory || w.temperatureF !== undefined) out += 't' + weatherValue(w.temperatureF, 3, -99, 999, 'temperature');
|
|
234
|
+
if (w.rain1hIn !== undefined) out += 'r' + weatherValue(w.rain1hIn * 100, 3, 0, 999, 'rain in the last hour');
|
|
235
|
+
if (w.rain24hIn !== undefined) out += 'p' + weatherValue(w.rain24hIn * 100, 3, 0, 999, 'rain in the last 24 hours');
|
|
236
|
+
if (w.rainMidnightIn !== undefined) out += 'P' + weatherValue(w.rainMidnightIn * 100, 3, 0, 999, 'rain since midnight');
|
|
237
|
+
if (w.humidityPercent !== undefined) {
|
|
238
|
+
const h = whole(w.humidityPercent, 1, 100, 'humidity');
|
|
239
|
+
out += 'h' + (h === 100 ? '00' : pad(h, 2));
|
|
240
|
+
}
|
|
241
|
+
if (w.pressureMbar !== undefined) out += 'b' + weatherValue(w.pressureMbar * 10, 5, 0, 99999, 'pressure');
|
|
242
|
+
if (w.luminosityWM2 !== undefined) {
|
|
243
|
+
const l = whole(w.luminosityWM2, 0, 1999, 'luminosity');
|
|
244
|
+
out += l < 1000 ? 'L' + pad(l, 3) : 'l' + pad(l - 1000, 3);
|
|
245
|
+
}
|
|
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
|
+
}
|
|
252
|
+
if (w.rainRaw !== undefined) out += '#' + pad(whole(w.rainRaw, 0, 999, 'raw rain counter'), 3);
|
|
253
|
+
for (const e of w.extra ?? []) {
|
|
254
|
+
if (!/^[A-Za-z]$/.test(e.letter) || 'csgtrpPhbLl'.includes(e.letter)) refuse(`${e.letter} is not an extra weather field letter`);
|
|
255
|
+
if (!/^[0-9.-]{2,}$/.test(e.value) || !/[0-9]$/.test(e.value)) refuse('an extra weather field is digits, dots or -, ending in a digit');
|
|
256
|
+
out += e.letter + e.value;
|
|
257
|
+
}
|
|
258
|
+
if (w.software !== undefined || w.unit !== undefined) {
|
|
259
|
+
if (w.software === undefined || !/^[A-Za-z]$/.test(w.software)) refuse('the weather software type is one letter');
|
|
260
|
+
if (w.unit === undefined || !/^[A-Za-z0-9_-]{2,4}$/.test(w.unit) || /^[0-9]+$/.test(w.unit))
|
|
261
|
+
refuse('the weather unit is 2-4 letters, digits, - or _');
|
|
262
|
+
out += w.software + w.unit;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A positionless weather report (`_`). */
|
|
268
|
+
export function encodeWeatherReport(d: WeatherReport): string {
|
|
269
|
+
if (!d.timestamp || d.timestamp.kind !== 'mdhm') refuse('a positionless weather report has a month/day/hour/minute timestamp');
|
|
270
|
+
if (!isValidTimestamp(d.timestamp)) refuse('the timestamp is out of range');
|
|
271
|
+
if (d.comment !== undefined && d.comment.length > 0) refuse('a weather report has no comment');
|
|
272
|
+
const t = d.timestamp;
|
|
273
|
+
const w = d.weather;
|
|
274
|
+
const wind = 'c' + weatherValue(w.windDirectionDegrees, 3, 0, 360, 'wind direction') + 's' + weatherValue(w.windSpeedMph, 3, 0, 999, 'wind speed');
|
|
275
|
+
return `_${pad(t.month, 2)}${pad(t.day, 2)}${pad(t.hour, 2)}${pad(t.minute, 2)}${wind}${weatherFields(w, true)}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- the positioned body
|
|
279
|
+
|
|
280
|
+
interface Body {
|
|
281
|
+
/** Everything after the DTI (and timestamp / name): position, extension, comment... */
|
|
282
|
+
text: string;
|
|
283
|
+
/** The compression type written, if any (the round-trip check expects it back). */
|
|
284
|
+
compression?: CompressionType;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function hasExtension(f: PositionedFields): string[] {
|
|
288
|
+
const found: string[] = [];
|
|
289
|
+
if (f.phg) found.push('PHG');
|
|
290
|
+
if (f.rangeMiles !== undefined) found.push('range');
|
|
291
|
+
if (f.dfs) found.push('DFS');
|
|
292
|
+
if (f.area) found.push('area');
|
|
293
|
+
return found;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How the comment is joined on: `plain` (after a space when it follows a frequency), `joined`
|
|
298
|
+
* (straight after a frequency, with no space) or `delimited` (after a `/`), for when it would
|
|
299
|
+
* otherwise read as something else.
|
|
300
|
+
*/
|
|
301
|
+
export type CommentMode = 'plain' | 'joined' | 'delimited';
|
|
302
|
+
|
|
303
|
+
/** Encodes a position and what follows it. */
|
|
304
|
+
function positionedBody(f: PositionedFields, mode: CommentMode): Body {
|
|
305
|
+
const isWeather = f.symbol.code === '_';
|
|
306
|
+
if (f.weather && !isWeather) refuse('weather data needs the weather station symbol (_)');
|
|
307
|
+
if (isWeather && !f.weather) refuse('the weather station symbol (_) needs weather data');
|
|
308
|
+
if (isWeather) return weatherBody(f);
|
|
309
|
+
const exts = hasExtension(f);
|
|
310
|
+
const hasCourse = f.courseDegrees !== undefined || f.speedKnots !== undefined;
|
|
311
|
+
if (exts.length + (hasCourse ? 1 : 0) > 1) refuse(`only one data extension can follow the symbol (${[hasCourse ? 'course/speed' : '', ...exts].filter(Boolean).join(', ')})`);
|
|
312
|
+
if (f.dfBearing && !(f.symbol.table === '/' && f.symbol.code === '\\')) refuse('a DF bearing needs the DF symbol (/\\)');
|
|
313
|
+
if (f.storm && !(f.symbol.code === '@' && (f.symbol.table === '/' || f.symbol.table === '\\'))) refuse('storm data needs a hurricane symbol (/@ or \\@)');
|
|
314
|
+
if (f.signpost !== undefined && !(f.symbol.table === '\\' && f.symbol.code === 'm')) refuse('a signpost needs the signpost symbol (\\m)');
|
|
315
|
+
if (f.area && !(f.symbol.table === '\\' && f.symbol.code === 'l')) refuse('an area object needs the area symbol (\\l)');
|
|
316
|
+
|
|
317
|
+
let out = '';
|
|
318
|
+
let compression: CompressionType | undefined;
|
|
319
|
+
let altitudeInCs = false;
|
|
320
|
+
let latRem = 0;
|
|
321
|
+
let lonRem = 0;
|
|
322
|
+
let daoApplied = false;
|
|
323
|
+
if (f.compressed) {
|
|
324
|
+
if (f.phg || f.dfs || f.area || f.dfBearing || f.storm) refuse('a compressed report carries only course/speed, range or altitude');
|
|
325
|
+
let cs: string | undefined;
|
|
326
|
+
compression = f.compression;
|
|
327
|
+
if (hasCourse) {
|
|
328
|
+
if (f.courseDegrees === undefined) refuse('a compressed course/speed needs a course');
|
|
329
|
+
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
330
|
+
const c = whole(f.courseDegrees, 1, 360, 'course') % 360;
|
|
331
|
+
if (c % 4 !== 0 && Math.abs(Math.round(c / 4) * 4 - f.courseDegrees) > 2) refuse('course is out of range');
|
|
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);
|
|
337
|
+
compression = compression ?? DEFAULT_COMPRESSION;
|
|
338
|
+
} else if (f.rangeMiles !== undefined) {
|
|
339
|
+
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
340
|
+
const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
|
|
341
|
+
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
342
|
+
cs = '{' + String.fromCharCode(s + 33);
|
|
343
|
+
compression = compression ?? DEFAULT_COMPRESSION;
|
|
344
|
+
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
345
|
+
if (f.altitudeFeet < 1) refuse('a compressed altitude is at least 1 foot');
|
|
346
|
+
const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
|
|
347
|
+
if (v > 91 * 91 - 1) refuse('altitude is too high for the compressed format');
|
|
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;
|
|
350
|
+
} else if (compression !== undefined) {
|
|
351
|
+
refuse('a compression type needs cs data (course/speed, range or altitude)');
|
|
352
|
+
}
|
|
353
|
+
out += compressedPosition(f, cs, compression);
|
|
354
|
+
} else {
|
|
355
|
+
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
356
|
+
const pos = uncompressedPosition(f, f.dao !== undefined);
|
|
357
|
+
out += pos.text;
|
|
358
|
+
latRem = pos.latRemainder;
|
|
359
|
+
lonRem = pos.lonRemainder;
|
|
360
|
+
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
361
|
+
if (hasCourse) {
|
|
362
|
+
out += courseSpeed(f.courseDegrees, f.speedKnots);
|
|
363
|
+
if (f.dfBearing) {
|
|
364
|
+
const b = f.dfBearing;
|
|
365
|
+
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
|
+
}
|
|
367
|
+
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
|
+
} else if (f.phg) {
|
|
371
|
+
const p = f.phg;
|
|
372
|
+
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')}`;
|
|
373
|
+
if (p.beaconsPerHour !== undefined) out += rateChar(p.beaconsPerHour) + '/';
|
|
374
|
+
} else if (f.rangeMiles !== undefined) {
|
|
375
|
+
out += 'RNG' + pad(whole(f.rangeMiles, 0, 9999, 'range'), 4);
|
|
376
|
+
} else if (f.dfs) {
|
|
377
|
+
const d = f.dfs;
|
|
378
|
+
out += `DFS${whole(d.strength, 0, 9, 'strength')}${heightChar(d.height)}${whole(d.gain, 0, 9, 'gain code')}${whole(d.directivity, 0, 9, 'directivity code')}`;
|
|
379
|
+
} else if (f.area) {
|
|
380
|
+
const a = f.area;
|
|
381
|
+
const shape = AREA_SHAPES.indexOf(a.shape);
|
|
382
|
+
const color = AREA_COLORS.indexOf(a.color);
|
|
383
|
+
if (shape < 0 || color < 0) refuse('unknown area shape or colour');
|
|
384
|
+
const c = color < 10 ? `/${color}` : String(color);
|
|
385
|
+
out += `${shape}${pad(whole(a.latOffset, 0, 99, 'area offset'), 2)}${c}${pad(whole(a.lonOffset, 0, 99, 'area offset'), 2)}`;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Altitude, unless the compressed cs bytes carry it exactly.
|
|
389
|
+
if (f.altitudeFeet !== undefined && !altitudeInCs) out += altitudeText(f.altitudeFeet);
|
|
390
|
+
const extensionEnded = !f.compressed && (hasCourse || exts.length > 0) && f.altitudeFeet === undefined;
|
|
391
|
+
let text = '';
|
|
392
|
+
// A frequency straight after a 7-byte extension is separated from it by / (a PHGR already ends in one).
|
|
393
|
+
if (f.frequency) text += (extensionEnded && !out.endsWith('/') ? '/' : '') + frequencyText(f.frequency);
|
|
394
|
+
let braces = '';
|
|
395
|
+
if (f.signpost !== undefined) {
|
|
396
|
+
if (!/^[\x21-\x7c\x7e]{1,3}$/.test(f.signpost) || f.signpost.includes('{') || f.signpost.includes('}')) refuse('a signpost is 1-3 printable characters');
|
|
397
|
+
braces = `{${f.signpost}}`;
|
|
398
|
+
}
|
|
399
|
+
if (f.area?.corridorWidthMiles !== undefined) {
|
|
400
|
+
if (f.area.shape !== 'line-down-right' && f.area.shape !== 'line-down-left') refuse('only a line has a corridor');
|
|
401
|
+
braces = `{${whole(f.area.corridorWidthMiles, 0, 999, 'corridor width')}}`;
|
|
402
|
+
}
|
|
403
|
+
const comment = f.comment ?? '';
|
|
404
|
+
if (comment.length > 0 || mode === 'delimited') {
|
|
405
|
+
if (text.length > 0 && mode !== 'joined') text += ' ';
|
|
406
|
+
text += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
407
|
+
}
|
|
408
|
+
text += braces;
|
|
409
|
+
if (f.telemetry) text += telemetryText(f.telemetry);
|
|
410
|
+
if (f.dao) text += daoText(f, latRem, lonRem, daoApplied);
|
|
411
|
+
const body: Body = { text: out + text };
|
|
412
|
+
if (compression) body.compression = compression;
|
|
413
|
+
return body;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function stormText(s: NonNullable<PositionedFields['storm']>): string {
|
|
417
|
+
const type = s.type === 'tropical-storm' ? 'TS' : s.type === 'hurricane' ? 'HC' : s.type === 'tropical-depression' ? 'TD' : refuse('unknown storm type');
|
|
418
|
+
const v = (n: number | undefined, width: number, what: string): string => (n === undefined ? ' '.repeat(width) : pad(whole(n, 0, 10 ** width - 1, what), width));
|
|
419
|
+
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
|
+
if (s.wholeGaleRadiusNm !== undefined) out += `%${v(s.wholeGaleRadiusNm, 3, 'radius')}`;
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function weatherBody(f: PositionedFields): Body {
|
|
425
|
+
const w = f.weather!;
|
|
426
|
+
if (f.comment !== undefined && f.comment.length > 0) refuse('a weather report has no comment');
|
|
427
|
+
if (f.phg || f.dfs || f.area || f.dfBearing || f.storm || f.frequency || f.signpost !== undefined)
|
|
428
|
+
refuse('a weather report carries only weather data');
|
|
429
|
+
if (f.rangeMiles !== undefined && !f.compressed) refuse('a weather report carries only weather data');
|
|
430
|
+
if (f.courseDegrees !== undefined || f.speedKnots !== undefined) refuse('a weather report has wind, not course and speed');
|
|
431
|
+
if (f.telemetry) refuse('comment telemetry goes in a comment, which a weather report does not have');
|
|
432
|
+
let out = '';
|
|
433
|
+
let compression: CompressionType | undefined;
|
|
434
|
+
let latRem = 0;
|
|
435
|
+
let lonRem = 0;
|
|
436
|
+
let daoApplied = false;
|
|
437
|
+
let altitudeInCs = false;
|
|
438
|
+
if (f.compressed) {
|
|
439
|
+
compression = f.compression;
|
|
440
|
+
let cs: string | undefined;
|
|
441
|
+
if (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined) {
|
|
442
|
+
if (w.windDirectionDegrees === undefined || w.windSpeedMph === undefined) refuse('compressed wind needs both direction and speed');
|
|
443
|
+
const dir = whole(w.windDirectionDegrees, 0, 360, 'wind direction');
|
|
444
|
+
const knots = w.windSpeedMph / KNOTS_TO_MPH;
|
|
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);
|
|
448
|
+
compression = compression ?? DEFAULT_COMPRESSION;
|
|
449
|
+
if (compression.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
450
|
+
} else if (f.altitudeFeet !== undefined && compression?.source === 'gga') {
|
|
451
|
+
const v = Math.round(Math.log(f.altitudeFeet) / Math.log(1.002));
|
|
452
|
+
cs = String.fromCharCode(Math.floor(v / 91) + 33) + String.fromCharCode((v % 91) + 33);
|
|
453
|
+
altitudeInCs = Math.abs(Math.pow(1.002, v) - f.altitudeFeet) <= 1e-9 * f.altitudeFeet;
|
|
454
|
+
} else if (f.rangeMiles !== undefined) {
|
|
455
|
+
if (compression?.source === 'gga') refuse('a GGA compression type means the cs bytes hold altitude');
|
|
456
|
+
const s = Math.round(Math.log(f.rangeMiles / 2) / Math.log(1.08));
|
|
457
|
+
if (!(s >= 0 && s <= 90)) refuse('range is out of range for the compressed format');
|
|
458
|
+
cs = '{' + String.fromCharCode(s + 33);
|
|
459
|
+
compression = compression ?? DEFAULT_COMPRESSION;
|
|
460
|
+
} else if (compression !== undefined) {
|
|
461
|
+
refuse('a compression type needs cs data');
|
|
462
|
+
}
|
|
463
|
+
out += compressedPosition(f, cs, compression);
|
|
464
|
+
if (f.altitudeFeet !== undefined && !altitudeInCs) refuse('a weather report has no altitude in its comment');
|
|
465
|
+
out += weatherFields(w, true);
|
|
466
|
+
} else {
|
|
467
|
+
if (f.compression) refuse('an uncompressed position has no compression type');
|
|
468
|
+
if (f.altitudeFeet !== undefined) refuse('a weather report has no altitude in its comment');
|
|
469
|
+
const pos = uncompressedPosition(f, f.dao !== undefined);
|
|
470
|
+
out += pos.text;
|
|
471
|
+
latRem = pos.latRemainder;
|
|
472
|
+
lonRem = pos.lonRemainder;
|
|
473
|
+
daoApplied = (f.ambiguity ?? 0) === 0;
|
|
474
|
+
out += courseSpeed(w.windDirectionDegrees, w.windSpeedMph, 'wind direction');
|
|
475
|
+
out += weatherFields(w, true);
|
|
476
|
+
}
|
|
477
|
+
if (f.telemetry) out += telemetryText(f.telemetry);
|
|
478
|
+
if (f.dao) out += daoText(f, latRem, lonRem, daoApplied);
|
|
479
|
+
const body: Body = { text: out };
|
|
480
|
+
if (compression) body.compression = compression;
|
|
481
|
+
return body;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---- the reports
|
|
485
|
+
|
|
486
|
+
export function encodePositionReport(d: PositionReport, mode: CommentMode): Body {
|
|
487
|
+
let dti: string;
|
|
488
|
+
let ts = '';
|
|
489
|
+
if (d.timestamp) {
|
|
490
|
+
ts = timestamp7(d.timestamp);
|
|
491
|
+
dti = d.messaging ? '@' : '/';
|
|
492
|
+
} else {
|
|
493
|
+
dti = d.messaging ? '=' : '!';
|
|
494
|
+
}
|
|
495
|
+
const body = positionedBody(d, mode);
|
|
496
|
+
return { ...body, text: dti + ts + body.text };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function encodeObject(d: ObjectReport, mode: CommentMode): Body {
|
|
500
|
+
if (!/^[\x20-\x7e]{1,9}$/.test(d.name)) refuse('an object name is 1-9 printable ASCII characters');
|
|
501
|
+
if (d.name.endsWith(' ')) refuse('an object name cannot end in a space, which reads as padding');
|
|
502
|
+
if (!d.timestamp) refuse('an object report always has a timestamp');
|
|
503
|
+
const ts = timestamp7(d.timestamp);
|
|
504
|
+
const body = positionedBody(d, mode);
|
|
505
|
+
return { ...body, text: ';' + d.name.padEnd(9, ' ') + (d.killed ? '_' : '*') + ts + body.text };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function encodeItem(d: ItemReport, mode: CommentMode): Body {
|
|
509
|
+
if (!/^[\x20-\x7e]{3,9}$/.test(d.name)) refuse('an item name is 3-9 printable ASCII characters');
|
|
510
|
+
if (/[!_]/.test(d.name)) refuse('an item name cannot contain ! or _');
|
|
511
|
+
const body = positionedBody(d, mode);
|
|
512
|
+
return { ...body, text: ')' + d.name + (d.killed ? '_' : '!') + body.text };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---- Mic-E (APRS12c ch. 10)
|
|
516
|
+
|
|
517
|
+
const STANDARD: Record<string, number> = {
|
|
518
|
+
'off-duty': 7,
|
|
519
|
+
'en-route': 6,
|
|
520
|
+
'in-service': 5,
|
|
521
|
+
returning: 4,
|
|
522
|
+
committed: 3,
|
|
523
|
+
special: 2,
|
|
524
|
+
priority: 1,
|
|
525
|
+
emergency: 0,
|
|
526
|
+
};
|
|
527
|
+
const CUSTOM: Record<string, number> = { custom0: 7, custom1: 6, custom2: 5, custom3: 4, custom4: 3, custom5: 2, custom6: 1 };
|
|
528
|
+
|
|
529
|
+
function micEDestination(d: MicEReport, lat: Digits, lonDegrees: number): string {
|
|
530
|
+
const message: MicEMessage = d.micEMessage;
|
|
531
|
+
let bits: number;
|
|
532
|
+
let custom = false;
|
|
533
|
+
if (message in STANDARD) bits = STANDARD[message]!;
|
|
534
|
+
else if (message in CUSTOM) {
|
|
535
|
+
bits = CUSTOM[message]!;
|
|
536
|
+
custom = true;
|
|
537
|
+
} else refuse(`the Mic-E message ${message} has no encoding`);
|
|
538
|
+
const ambiguity = d.ambiguity ?? 0;
|
|
539
|
+
const digits = pad(lat.degrees, 2) + pad(lat.hundredths, 4);
|
|
540
|
+
let out = '';
|
|
541
|
+
for (let k = 0; k < 6; k++) {
|
|
542
|
+
const blanked = k >= 6 - ambiguity;
|
|
543
|
+
const digit = digits.charCodeAt(k) - 48;
|
|
544
|
+
let flag: boolean;
|
|
545
|
+
if (k < 3) flag = ((bits >> (2 - k)) & 1) === 1;
|
|
546
|
+
else if (k === 3) flag = !isNegative(d.latitude);
|
|
547
|
+
else if (k === 4) flag = lonDegrees <= 9 || lonDegrees >= 100;
|
|
548
|
+
else flag = isNegative(d.longitude);
|
|
549
|
+
if (k < 3 && flag && custom) out += blanked ? 'K' : String.fromCharCode(65 + digit);
|
|
550
|
+
else if (flag) out += blanked ? 'Z' : String.fromCharCode(80 + digit);
|
|
551
|
+
else out += blanked ? 'L' : String.fromCharCode(48 + digit);
|
|
552
|
+
}
|
|
553
|
+
if (d.destinationSsid !== undefined && d.destinationSsid !== 0) {
|
|
554
|
+
out += '-' + whole(d.destinationSsid, 0, 15, 'destination SSID');
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function micEAltitude(feet: number): string | undefined {
|
|
560
|
+
const metres = Math.round(feet * METRES_PER_FOOT);
|
|
561
|
+
if (Math.abs(metres / METRES_PER_FOOT - feet) > 1e-6) return undefined;
|
|
562
|
+
const v = metres + 10000;
|
|
563
|
+
if (v < 0 || v > 91 ** 3 - 1) return undefined;
|
|
564
|
+
return base91(v, 3) + '}';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** A Mic-E report: the information field and the destination address. */
|
|
568
|
+
export function encodeMicE(d: MicEReport, mode: CommentMode): { text: string; destination: string } {
|
|
569
|
+
if (d.compressed || d.compression) refuse('a Mic-E report is not compressed');
|
|
570
|
+
if (!(d.latitude >= -90 && d.latitude <= 90)) refuse(`latitude ${d.latitude} is out of range`);
|
|
571
|
+
if (!(d.longitude >= -180 && d.longitude <= 180)) refuse(`longitude ${d.longitude} is out of range`);
|
|
572
|
+
checkSymbol(d.symbol);
|
|
573
|
+
if (d.weather) refuse('a Mic-E report has no weather data');
|
|
574
|
+
if (d.dfBearing || d.storm || d.area || d.signpost !== undefined) refuse('a Mic-E report cannot carry that data');
|
|
575
|
+
const ambiguity = d.ambiguity ?? 0;
|
|
576
|
+
if (!Number.isInteger(ambiguity) || ambiguity < 0 || ambiguity > 4) refuse('ambiguity is 0-4 digits');
|
|
577
|
+
const truncate = d.dao !== undefined || ambiguity > 0;
|
|
578
|
+
const lat = digitsOf(d.latitude, truncate);
|
|
579
|
+
const lon = digitsOf(d.longitude, truncate);
|
|
580
|
+
if (lat.degrees > 90 || (lat.degrees === 90 && lat.hundredths > 0)) refuse('latitude is out of range');
|
|
581
|
+
if (lon.degrees > 179) refuse('Mic-E longitude is below 180 degrees');
|
|
582
|
+
const destination = micEDestination(d, lat, lon.degrees);
|
|
583
|
+
// Longitude
|
|
584
|
+
const deg = lon.degrees;
|
|
585
|
+
let dByte: number;
|
|
586
|
+
if (deg <= 9) dByte = deg + 90 + 28;
|
|
587
|
+
else if (deg <= 99) dByte = deg + 28;
|
|
588
|
+
else if (deg <= 109) dByte = deg + 8;
|
|
589
|
+
else dByte = deg - 100 + 28;
|
|
590
|
+
// With ambiguity the digits a decoder ignores are written as the centre it reports.
|
|
591
|
+
const mValue = Math.floor(lon.hundredths / 100);
|
|
592
|
+
const hundredths = lon.hundredths % 100;
|
|
593
|
+
const mByte = mValue < 10 ? mValue + 88 : mValue + 28;
|
|
594
|
+
const hByte = hundredths + 28;
|
|
595
|
+
// Speed and course: speed tens + 80 (so values under 200 knots are printable), course + 400.
|
|
596
|
+
const speed = d.speedKnots === undefined ? 0 : whole(d.speedKnots, 0, 799, 'speed');
|
|
597
|
+
const course = d.courseDegrees === undefined ? 0 : whole(d.courseDegrees, 0, 360, 'course');
|
|
598
|
+
const tens = Math.floor(speed / 10);
|
|
599
|
+
const spByte = tens < 20 ? tens + 80 + 28 : tens + 28;
|
|
600
|
+
const dcByte = (speed % 10) * 10 + Math.floor(course / 100) + 4 + 28;
|
|
601
|
+
const seByte = (course % 100) + 28;
|
|
602
|
+
const dti = d.oldData ? "'" : '`';
|
|
603
|
+
let info = dti + String.fromCharCode(dByte, mByte, hByte, spByte, dcByte, seByte) + d.symbol.code + d.symbol.table;
|
|
604
|
+
|
|
605
|
+
// Status text: type code, altitude, locator, then the comment elements, then the suffix.
|
|
606
|
+
let text = '';
|
|
607
|
+
if (d.typeCode !== undefined) {
|
|
608
|
+
if (!'`\'>] '.includes(d.typeCode) || d.typeCode.length !== 1) refuse('the Mic-E type code is `, \', >, ] or a space');
|
|
609
|
+
text += d.typeCode;
|
|
610
|
+
}
|
|
611
|
+
let altitudeInComment: number | undefined;
|
|
612
|
+
if (d.altitudeFeet !== undefined) {
|
|
613
|
+
const alt = micEAltitude(d.altitudeFeet);
|
|
614
|
+
if (alt !== undefined) text += alt;
|
|
615
|
+
else altitudeInComment = d.altitudeFeet;
|
|
616
|
+
}
|
|
617
|
+
let after = '';
|
|
618
|
+
const exts = [d.phg ? 1 : 0, d.rangeMiles !== undefined ? 1 : 0, d.dfs ? 1 : 0].reduce((a, b) => a + b, 0);
|
|
619
|
+
if (exts > 1) refuse('only one data extension can follow');
|
|
620
|
+
if (d.phg) {
|
|
621
|
+
const p = d.phg;
|
|
622
|
+
after += `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')}`;
|
|
623
|
+
if (p.beaconsPerHour !== undefined) after += rateChar(p.beaconsPerHour) + '/';
|
|
624
|
+
} else if (d.rangeMiles !== undefined) {
|
|
625
|
+
after += 'RNG' + pad(whole(d.rangeMiles, 0, 9999, 'range'), 4);
|
|
626
|
+
} else if (d.dfs) {
|
|
627
|
+
const x = d.dfs;
|
|
628
|
+
after += `DFS${whole(x.strength, 0, 9, 'strength')}${heightChar(x.height)}${whole(x.gain, 0, 9, 'gain code')}${whole(x.directivity, 0, 9, 'directivity code')}`;
|
|
629
|
+
}
|
|
630
|
+
if (altitudeInComment !== undefined) after += altitudeText(altitudeInComment);
|
|
631
|
+
let rest = '';
|
|
632
|
+
if (d.frequency) rest += (exts > 0 && altitudeInComment === undefined && !after.endsWith('/') ? '/' : '') + frequencyText(d.frequency);
|
|
633
|
+
const comment = d.comment ?? '';
|
|
634
|
+
if (comment.length > 0 || mode === 'delimited') {
|
|
635
|
+
if (rest.length > 0 && mode !== 'joined') rest += ' ';
|
|
636
|
+
rest += (mode === 'delimited' ? '/' : '') + freeText(comment, 'the comment');
|
|
637
|
+
}
|
|
638
|
+
after += rest;
|
|
639
|
+
if (d.telemetry) after += telemetryText(d.telemetry);
|
|
640
|
+
if (d.dao) after += daoText(d, lat.remainder, lon.remainder, ambiguity === 0);
|
|
641
|
+
if (d.locator !== undefined) {
|
|
642
|
+
if (!/^[A-R]{2}[0-9]{2}(?:[A-X]{2})?$/.test(d.locator)) refuse('the locator is 4 or 6 characters, upper case');
|
|
643
|
+
text += `${d.locator}/G`;
|
|
644
|
+
if (after.length > 0) text += ' ';
|
|
645
|
+
}
|
|
646
|
+
text += after;
|
|
647
|
+
if (d.deviceSuffix !== undefined) {
|
|
648
|
+
if (d.typeCode === undefined) refuse('a Mic-E device suffix needs a type code');
|
|
649
|
+
text += d.deviceSuffix;
|
|
650
|
+
}
|
|
651
|
+
if (d.legacyTelemetry && d.legacyTelemetry.length > 0) refuse('obsolete Mic-E telemetry is not written');
|
|
652
|
+
info += text;
|
|
653
|
+
return { text: info, destination };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export type { AprsSymbol };
|