@packet-net/pdn-aprs 0.1.1 → 0.2.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 +15 -0
- package/README.md +2 -2
- 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.js +3 -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 +10 -2
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +129 -77
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +24 -4
- package/dist/decode/reports.js.map +1 -1
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +18 -4
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +29 -12
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +35 -15
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +4 -2
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +58 -22
- 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/neutral.d.ts.map +1 -1
- package/dist/neutral.js +2 -0
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +6 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bytes.ts +2 -1
- package/src/codes.ts +8 -8
- package/src/decode/index.ts +3 -4
- package/src/decode/message.ts +54 -37
- package/src/decode/other.ts +116 -44
- package/src/decode/positioned.ts +133 -88
- package/src/decode/reports.ts +22 -3
- package/src/deviceid.ts +17 -2
- package/src/encode/index.ts +30 -12
- package/src/encode/other.ts +27 -11
- package/src/encode/position.ts +54 -20
- package/src/header.ts +9 -2
- package/src/neutral.ts +2 -0
- package/src/types.ts +6 -4
package/src/decode/other.ts
CHANGED
|
@@ -55,7 +55,8 @@ export function decodeStatus(ctx: DecodeContext, s: string): StatusReport {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// The ERP codes run 1-9, : to @, then A-K; there is no 0 (APRS12c ch. 16).
|
|
59
|
+
const beam = /\^([0-9A-Z])([1-9:;<=>?@A-K])$/.exec(body);
|
|
59
60
|
if (beam) {
|
|
60
61
|
out.beam = { headingCode: beam[1]!, powerCode: beam[2]! };
|
|
61
62
|
body = body.slice(0, beam.index);
|
|
@@ -130,6 +131,10 @@ export function decodeTelemetry(ctx: DecodeContext, s: string): TelemetryReport
|
|
|
130
131
|
return out;
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
const FOOTPRINT_NUMBER = '[0-9]+\\.?[0-9]*|\\.[0-9]+';
|
|
135
|
+
const FOOTPRINT_VALUE = `( ?(?:${FOOTPRINT_NUMBER})|-(?:${FOOTPRINT_NUMBER}))`;
|
|
136
|
+
const FOOTPRINT_RE = new RegExp(`^${FOOTPRINT_VALUE},${FOOTPRINT_VALUE},([0-9]{4})$`);
|
|
137
|
+
|
|
133
138
|
/** `?`: a general query. */
|
|
134
139
|
export function decodeQuery(ctx: DecodeContext, s: string): Query {
|
|
135
140
|
const m = /^\?([A-Z]+)\?(.*)$/s.exec(s);
|
|
@@ -137,98 +142,164 @@ export function decodeQuery(ctx: DecodeContext, s: string): Query {
|
|
|
137
142
|
const out: { -readonly [K in keyof Query]: Query[K] } = { type: 'query', queryType: m[1]! };
|
|
138
143
|
const rest = m[2]!;
|
|
139
144
|
if (rest.length > 0) {
|
|
140
|
-
|
|
145
|
+
// Decimal degrees, each a number as a telemetry value is (`-.1715`), a positive one with or
|
|
146
|
+
// without a leading space, and a 4-digit radius.
|
|
147
|
+
const f = FOOTPRINT_RE.exec(rest);
|
|
141
148
|
if (!f) ctx.fail('invalid-general-query');
|
|
142
|
-
|
|
149
|
+
const latitude = Number(f[1]);
|
|
150
|
+
const longitude = Number(f[2]);
|
|
151
|
+
// A footprint is a real place (vectors interpretations.md).
|
|
152
|
+
if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) ctx.fail('invalid-general-query');
|
|
153
|
+
out.footprint = { latitude, longitude, radiusMiles: Number(f[3]) };
|
|
143
154
|
}
|
|
144
155
|
return out;
|
|
145
156
|
}
|
|
146
157
|
|
|
158
|
+
/** Drops U+0020 spaces, and no other character, from both ends. */
|
|
159
|
+
function trimSpaces(text: string): string {
|
|
160
|
+
let a = 0;
|
|
161
|
+
let b = text.length;
|
|
162
|
+
while (a < b && text[a] === ' ') a++;
|
|
163
|
+
while (b > a && text[b - 1] === ' ') b--;
|
|
164
|
+
return text.slice(a, b);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A control character: below U+0020, or U+007F. */
|
|
168
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
169
|
+
|
|
147
170
|
/** `<`: station capabilities. */
|
|
148
171
|
export function decodeCapabilities(ctx: DecodeContext, s: string): Capabilities {
|
|
149
172
|
const text = ctx.text(s.slice(1));
|
|
150
173
|
const capabilities: Capability[] = [];
|
|
151
174
|
let freeText = false;
|
|
152
175
|
for (const raw of text.split(',')) {
|
|
153
|
-
|
|
176
|
+
// Spaces around an item, a token or a value are padding; nothing else is (vectors
|
|
177
|
+
// interpretations.md, "Station capabilities: items, tokens and values").
|
|
178
|
+
const item = trimSpaces(raw);
|
|
154
179
|
if (item.length === 0) continue; // empty items are skipped
|
|
155
180
|
const eq = item.indexOf('=');
|
|
156
|
-
const token = eq < 0 ? item : item.slice(0, eq);
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
const token = eq < 0 ? item : trimSpaces(item.slice(0, eq));
|
|
182
|
+
const value = eq < 0 ? undefined : trimSpaces(item.slice(eq + 1));
|
|
183
|
+
if (token.length === 0 || token.includes(' ') || CONTROL_RE.test(token)) freeText = true;
|
|
184
|
+
if (value !== undefined && CONTROL_RE.test(value)) freeText = true;
|
|
185
|
+
capabilities.push(value === undefined ? [token] : [token, value]);
|
|
159
186
|
}
|
|
160
187
|
if (capabilities.length === 0) ctx.fail('invalid-capabilities');
|
|
161
188
|
if (freeText) ctx.tolerate('free-text-capabilities');
|
|
162
189
|
return { type: 'capabilities', capabilities };
|
|
163
190
|
}
|
|
164
191
|
|
|
165
|
-
|
|
192
|
+
/** A coordinate: at least three digits before an optional `.` and fraction, the last two minutes. */
|
|
193
|
+
function nmeaDegrees(value: string | undefined, hemisphere: string | undefined, limit: number): number | undefined {
|
|
166
194
|
if (value === undefined || hemisphere === undefined) return undefined;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const degrees = Number(intPart.slice(0, intPart.length - 2));
|
|
172
|
-
const minutes = Number(value.slice(intPart.length - 2));
|
|
195
|
+
const m = /^([0-9]+)([0-9]{2}(?:\.[0-9]*)?)$/.exec(value);
|
|
196
|
+
if (!m) return undefined;
|
|
197
|
+
const degrees = Number(m[1]);
|
|
198
|
+
const minutes = Number(m[2]);
|
|
173
199
|
if (minutes >= 60) return undefined;
|
|
174
200
|
const v = degrees + minutes / 60;
|
|
175
|
-
if (v >
|
|
176
|
-
if (hemisphere === 'S'
|
|
177
|
-
if (hemisphere === 'N'
|
|
201
|
+
if (v > limit) return undefined;
|
|
202
|
+
if (hemisphere === (limit === 90 ? 'S' : 'W')) return v === 0 ? 0 : -v;
|
|
203
|
+
if (hemisphere === (limit === 90 ? 'N' : 'E')) return v;
|
|
178
204
|
return undefined;
|
|
179
205
|
}
|
|
180
206
|
|
|
207
|
+
/** `hhmmss` with an optional fraction, as `HH:MM:SS` and the fraction as sent, less trailing zeros. */
|
|
181
208
|
function nmeaTime(value: string | undefined): string | undefined {
|
|
182
209
|
if (value === undefined) return undefined;
|
|
183
210
|
const m = /^([0-9]{2})([0-9]{2})([0-9]{2})(?:\.([0-9]*))?$/.exec(value);
|
|
184
211
|
if (!m) return undefined;
|
|
212
|
+
if (Number(m[1]) > 23 || Number(m[2]) > 59 || Number(m[3]) > 59) return undefined;
|
|
185
213
|
let t = `${m[1]}:${m[2]}:${m[3]}`;
|
|
186
214
|
const fraction = (m[4] ?? '').replace(/0+$/, '');
|
|
187
215
|
if (fraction.length > 0) t += `.${fraction}`;
|
|
188
216
|
return t;
|
|
189
217
|
}
|
|
190
218
|
|
|
219
|
+
/**
|
|
220
|
+
* An optional `-`, then digits with an optional `.` and fraction. NMEA 0183 makes leading zeros
|
|
221
|
+
* optional, so a number may start with its decimal point (`.30`).
|
|
222
|
+
*/
|
|
191
223
|
function nmeaNumber(value: string | undefined): number | undefined {
|
|
192
|
-
if (value === undefined || !/^-?(?:[0-9]
|
|
224
|
+
if (value === undefined || !/^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)$/.test(value)) return undefined;
|
|
193
225
|
return Number(value);
|
|
194
226
|
}
|
|
195
227
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
228
|
+
const NMEA_ADDRESS_RE = /^(?:[A-Z0-9]{5}|P[A-Z0-9]{3,})$/;
|
|
229
|
+
|
|
230
|
+
/** An NMEA 0183 sentence taken apart. */
|
|
231
|
+
export interface NmeaParts {
|
|
232
|
+
/** The sentence, up to and including any `*hh` checksum. */
|
|
233
|
+
sentence: string;
|
|
234
|
+
/** The comma-separated fields before any checksum, the address field first. */
|
|
235
|
+
fields: string[];
|
|
236
|
+
/** Whether the sentence has a checksum. */
|
|
237
|
+
hasChecksum: boolean;
|
|
238
|
+
/** Whether the checksum matches; true when there is none. */
|
|
239
|
+
checksumOk: boolean;
|
|
240
|
+
/** Whatever follows the checksum. */
|
|
241
|
+
rest: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Takes the text after `$` apart as an NMEA 0183 sentence (vectors interpretations.md, "What $
|
|
246
|
+
* text is an NMEA sentence"): printable ASCII, an address field of five upper-case letters or
|
|
247
|
+
* digits (or `P` and three or more), at least one field, no `$`, and no `*` but the one that
|
|
248
|
+
* starts a checksum of two hex digits, which ends the sentence. `undefined` when it is not one.
|
|
249
|
+
*/
|
|
250
|
+
export function splitNmea(text: string): NmeaParts | undefined {
|
|
251
|
+
const star = text.indexOf('*');
|
|
252
|
+
const hasChecksum = star >= 0;
|
|
253
|
+
if (hasChecksum && !/^[0-9A-Fa-f]{2}$/.test(text.slice(star + 1, star + 3))) return undefined;
|
|
254
|
+
const body = hasChecksum ? text.slice(0, star) : text;
|
|
255
|
+
if (!/^[\x20-\x7e]*$/.test(body) || body.includes('$')) return undefined;
|
|
256
|
+
const fields = body.split(',');
|
|
257
|
+
if (fields.length < 2 || !NMEA_ADDRESS_RE.test(fields[0]!)) return undefined;
|
|
258
|
+
const end = hasChecksum ? star + 3 : text.length;
|
|
259
|
+
let checksumOk = true;
|
|
260
|
+
if (hasChecksum) {
|
|
261
|
+
let x = 0;
|
|
262
|
+
for (let k = 0; k < body.length; k++) x ^= body.charCodeAt(k);
|
|
263
|
+
checksumOk = x === parseInt(text.slice(star + 1, star + 3), 16);
|
|
264
|
+
}
|
|
265
|
+
return { sentence: text.slice(0, end), fields, hasChecksum, checksumOk, rest: text.slice(end) };
|
|
203
266
|
}
|
|
204
267
|
|
|
205
268
|
/** `$`: raw NMEA, or Ultimeter raw weather (`$ULTW`). */
|
|
206
269
|
export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawWeather {
|
|
207
270
|
if (s.startsWith('$ULTW')) return decodeRawWeather(ctx, s.slice(5), 'ultimeter-packet');
|
|
208
271
|
ctx.info('obsolete-format');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
if (checksum !== undefined) out.hasChecksum = true;
|
|
272
|
+
// The structure is checked before the checksum.
|
|
273
|
+
const parts = splitNmea(s.slice(1));
|
|
274
|
+
if (!parts) return ctx.fail('invalid-nmea');
|
|
275
|
+
if (!parts.checksumOk) ctx.fail('nmea-checksum-mismatch');
|
|
276
|
+
const f = parts.fields;
|
|
277
|
+
const out: { -readonly [K in keyof NmeaSentence]: NmeaSentence[K] } = { type: 'nmea', sentence: parts.sentence };
|
|
278
|
+
if (parts.hasChecksum) out.hasChecksum = true;
|
|
217
279
|
const set = <K extends keyof NmeaSentence>(key: K, value: NmeaSentence[K] | undefined): void => {
|
|
218
280
|
if (value !== undefined) out[key] = value;
|
|
219
281
|
};
|
|
220
|
-
|
|
282
|
+
// A position needs both coordinates.
|
|
283
|
+
const position = (lat: number, lon: number): void => {
|
|
284
|
+
const latitude = nmeaDegrees(f[lat], f[lat + 1], 90);
|
|
285
|
+
const longitude = nmeaDegrees(f[lon], f[lon + 1], 180);
|
|
286
|
+
if (latitude === undefined || longitude === undefined) return;
|
|
287
|
+
out.latitude = latitude;
|
|
288
|
+
out.longitude = longitude;
|
|
289
|
+
};
|
|
290
|
+
// Only an approved address (five characters, not P) has a sentence formatter.
|
|
291
|
+
const address = f[0]!;
|
|
292
|
+
const formatter = address.length === 5 && address[0] !== 'P' ? address.slice(2) : '';
|
|
293
|
+
switch (formatter) {
|
|
221
294
|
case 'GGA': {
|
|
222
|
-
|
|
223
|
-
set('longitude', nmeaDegrees(f[4], f[5], 180));
|
|
295
|
+
position(2, 4);
|
|
224
296
|
if (f[6] !== undefined && /^[0-9]$/.test(f[6])) out.fix = f[6] === '0' ? 'invalid' : 'valid';
|
|
225
297
|
set('altitudeM', nmeaNumber(f[9]));
|
|
226
298
|
set('time', nmeaTime(f[1]));
|
|
227
299
|
break;
|
|
228
300
|
}
|
|
229
301
|
case 'GLL': {
|
|
230
|
-
|
|
231
|
-
set('longitude', nmeaDegrees(f[3], f[4], 180));
|
|
302
|
+
position(1, 3);
|
|
232
303
|
set('time', nmeaTime(f[5]));
|
|
233
304
|
if (f[6] === 'A') out.fix = 'valid';
|
|
234
305
|
else if (f[6] === 'V') out.fix = 'invalid';
|
|
@@ -238,8 +309,7 @@ export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawW
|
|
|
238
309
|
set('time', nmeaTime(f[1]));
|
|
239
310
|
if (f[2] === 'A') out.fix = 'valid';
|
|
240
311
|
else if (f[2] === 'V') out.fix = 'invalid';
|
|
241
|
-
|
|
242
|
-
set('longitude', nmeaDegrees(f[5], f[6], 180));
|
|
312
|
+
position(3, 5);
|
|
243
313
|
set('speedKnots', nmeaNumber(f[7]));
|
|
244
314
|
set('courseDegrees', nmeaNumber(f[8]));
|
|
245
315
|
break;
|
|
@@ -250,12 +320,13 @@ export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawW
|
|
|
250
320
|
break;
|
|
251
321
|
}
|
|
252
322
|
case 'WPL': {
|
|
253
|
-
|
|
254
|
-
set('longitude', nmeaDegrees(f[3], f[4], 180));
|
|
323
|
+
position(1, 3);
|
|
255
324
|
if (f[5] !== undefined && f[5].length > 0) out.waypoint = f[5];
|
|
256
325
|
break;
|
|
257
326
|
}
|
|
258
327
|
}
|
|
328
|
+
// Text after the checksum is a comment, kept as sent.
|
|
329
|
+
if (parts.rest.length > 0) out.comment = ctx.text(parts.rest);
|
|
259
330
|
return out;
|
|
260
331
|
}
|
|
261
332
|
|
|
@@ -287,8 +358,9 @@ export function decodeTest(ctx: DecodeContext, s: string): TestData {
|
|
|
287
358
|
|
|
288
359
|
/** `%`: an Agrelo DFJr / MicroFinder report. */
|
|
289
360
|
export function decodeAgrelo(ctx: DecodeContext, s: string): AgreloDf {
|
|
290
|
-
|
|
291
|
-
|
|
361
|
+
// Exactly %, a bearing of 000 to 360, / and a quality digit (vectors interpretations.md).
|
|
362
|
+
const m = /^%([0-9]{3})\/([0-9])$/.exec(s);
|
|
363
|
+
if (!m || Number(m[1]) > 360) return ctx.fail('invalid-agrelo-df');
|
|
292
364
|
return { type: 'agrelo-df', bearingDegrees: Number(m[1]), quality: Number(m[2]) };
|
|
293
365
|
}
|
|
294
366
|
|