@packet-net/pdn-aprs 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -1
- package/README.md +8 -4
- package/dist/builder.d.ts +33 -8
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +64 -10
- package/dist/builder.js.map +1 -1
- package/dist/decode/index.d.ts.map +1 -1
- package/dist/decode/index.js +5 -0
- package/dist/decode/index.js.map +1 -1
- package/dist/decode/other.js +1 -1
- package/dist/decode/other.js.map +1 -1
- package/dist/decode/positioned.d.ts +17 -1
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +21 -7
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +34 -12
- package/dist/decode/reports.js.map +1 -1
- package/dist/decoder.d.ts.map +1 -1
- package/dist/decoder.js +2 -4
- package/dist/decoder.js.map +1 -1
- package/dist/deviceid.d.ts +8 -2
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +10 -2
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/common.d.ts +13 -2
- package/dist/encode/common.d.ts.map +1 -1
- package/dist/encode/common.js +36 -4
- package/dist/encode/common.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +36 -16
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +10 -2
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +9 -5
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +219 -121
- package/dist/encode/position.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/neutral.d.ts.map +1 -1
- package/dist/neutral.js +20 -3
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +13 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +77 -14
- package/src/decode/index.ts +4 -0
- package/src/decode/other.ts +1 -1
- package/src/decode/positioned.ts +33 -9
- package/src/decode/reports.ts +33 -9
- package/src/decoder.ts +2 -5
- package/src/deviceid.ts +12 -3
- package/src/encode/common.ts +34 -4
- package/src/encode/index.ts +33 -14
- package/src/encode/other.ts +12 -2
- package/src/encode/position.ts +227 -119
- package/src/index.ts +1 -0
- package/src/neutral.ts +22 -3
- package/src/types.ts +14 -1
package/src/builder.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// `build()` encodes the packet, so anything the spec does not allow is refused there, with an
|
|
14
14
|
// `AprsEncodeError` that says why.
|
|
15
15
|
|
|
16
|
+
import { nearest, refuse } from './encode/common.js';
|
|
17
|
+
import { ALTITUDE_GIVEN_IN_FEET } from './encode/position.js';
|
|
16
18
|
import { encodeInformation, encodeAx25, encodeTnc2, encodeTnc2Bytes, wrapKiss, type PacketToEncode } from './encode/index.js';
|
|
17
19
|
import { symbolOf } from './symbols.js';
|
|
18
20
|
import type {
|
|
@@ -74,6 +76,36 @@ function built(header: Header, data: AprsData): BuiltPacket {
|
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Weather as a program may have it: the units APRS sends (Fahrenheit, inches), or Celsius and
|
|
81
|
+
* millimetres, which the builder converts (vectors rulings, B2). Give each value in one unit.
|
|
82
|
+
*/
|
|
83
|
+
export interface WeatherInput extends Weather {
|
|
84
|
+
readonly temperatureC?: number;
|
|
85
|
+
readonly rain1hMm?: number;
|
|
86
|
+
readonly rain24hMm?: number;
|
|
87
|
+
readonly rainMidnightMm?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MM_PER_INCH = 25.4;
|
|
91
|
+
|
|
92
|
+
/** Weather in the units APRS sends; a value in two units is refused. */
|
|
93
|
+
function weatherOf(input: WeatherInput): Weather {
|
|
94
|
+
const { temperatureC, rain1hMm, rain24hMm, rainMidnightMm, ...rest } = input;
|
|
95
|
+
const w: Mutable<Weather> = { ...rest };
|
|
96
|
+
const convert = (metric: number | undefined, key: 'temperatureF' | 'rain1hIn' | 'rain24hIn' | 'rainMidnightIn', f: (v: number) => number): void => {
|
|
97
|
+
if (metric === undefined) return;
|
|
98
|
+
if (w[key] !== undefined) refuse(`weather ${key} is given in two units`);
|
|
99
|
+
w[key] = f(metric);
|
|
100
|
+
};
|
|
101
|
+
// Converted here and rounded once, when the report is written.
|
|
102
|
+
convert(temperatureC, 'temperatureF', (c) => (c * 9) / 5 + 32);
|
|
103
|
+
convert(rain1hMm, 'rain1hIn', (mm) => mm / MM_PER_INCH);
|
|
104
|
+
convert(rain24hMm, 'rain24hIn', (mm) => mm / MM_PER_INCH);
|
|
105
|
+
convert(rainMidnightMm, 'rainMidnightIn', (mm) => mm / MM_PER_INCH);
|
|
106
|
+
return w;
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
function asSymbol(symbol: AprsSymbol | string): AprsSymbol {
|
|
78
110
|
return typeof symbol === 'string' ? symbolOf(symbol) : symbol;
|
|
79
111
|
}
|
|
@@ -122,7 +154,7 @@ export class PacketBuilder {
|
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
/** A weather report at a position (the weather station symbol, unless you pick another `_`). */
|
|
125
|
-
weather(latitude: number, longitude: number, weather:
|
|
157
|
+
weather(latitude: number, longitude: number, weather: WeatherInput): PositionBuilder {
|
|
126
158
|
return new PositionBuilder(this.header, latitude, longitude).symbol('/_').weather(weather);
|
|
127
159
|
}
|
|
128
160
|
|
|
@@ -166,10 +198,13 @@ export class PacketBuilder {
|
|
|
166
198
|
return new StatusBuilder(this.header, text);
|
|
167
199
|
}
|
|
168
200
|
|
|
169
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* A telemetry report: sequence, five analog values and eight bits (`'01101001'`, B1 first). A
|
|
203
|
+
* sequence given as a number is 0-999, written in three digits; a larger one is refused when
|
|
204
|
+
* the packet is built, not wrapped (vectors rulings, B6).
|
|
205
|
+
*/
|
|
170
206
|
telemetry(sequence: number | string, analog: readonly (number | null)[], bits: string): TelemetryBuilder {
|
|
171
|
-
|
|
172
|
-
return new TelemetryBuilder(this.header, seq, analog, bits);
|
|
207
|
+
return new TelemetryBuilder(this.header, sequence, analog, bits);
|
|
173
208
|
}
|
|
174
209
|
|
|
175
210
|
/** Telemetry channel names (`PARM.`), sent to the station whose telemetry they describe. */
|
|
@@ -195,7 +230,7 @@ export class PacketBuilder {
|
|
|
195
230
|
}
|
|
196
231
|
|
|
197
232
|
/** A positionless weather report (not recommended: a weather report with a position is better). */
|
|
198
|
-
weatherReport(weather:
|
|
233
|
+
weatherReport(weather: WeatherInput, at: Date = new Date()): DataBuilder {
|
|
199
234
|
const timestamp: AprsTimestamp = {
|
|
200
235
|
kind: 'mdhm',
|
|
201
236
|
month: at.getUTCMonth() + 1,
|
|
@@ -203,7 +238,7 @@ export class PacketBuilder {
|
|
|
203
238
|
hour: at.getUTCHours(),
|
|
204
239
|
minute: at.getUTCMinutes(),
|
|
205
240
|
};
|
|
206
|
-
return new DataBuilder(this.header, { type: 'weather', timestamp, weather });
|
|
241
|
+
return new DataBuilder(this.header, { type: 'weather', timestamp, weather: weatherOf(weather) });
|
|
207
242
|
}
|
|
208
243
|
|
|
209
244
|
/** Any data, built by hand. */
|
|
@@ -250,7 +285,11 @@ abstract class PositionedBuilder<Self> {
|
|
|
250
285
|
return this.self();
|
|
251
286
|
}
|
|
252
287
|
|
|
253
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* Course over the ground, degrees: 360 is north and 0 is unknown, as APRS has it (vectors
|
|
290
|
+
* rulings, B1). A course of 0 is written `000` in an uncompressed report, left out of a Mic-E
|
|
291
|
+
* report, and refused in a compressed one, whose bytes have no unknown course.
|
|
292
|
+
*/
|
|
254
293
|
course(degrees: number): Self {
|
|
255
294
|
this.fields.courseDegrees = degrees;
|
|
256
295
|
return this.self();
|
|
@@ -262,18 +301,32 @@ abstract class PositionedBuilder<Self> {
|
|
|
262
301
|
return this.self();
|
|
263
302
|
}
|
|
264
303
|
|
|
265
|
-
/**
|
|
304
|
+
/** Speed, km/h (sent in knots, 1.852 km/h each, rounded once to the knot). */
|
|
305
|
+
speedKmh(kmh: number): Self {
|
|
306
|
+
this.fields.speedKnots = kmh / 1.852;
|
|
307
|
+
return this.self();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Altitude, feet, sent as a `/A=` in whole feet (rounded to the nearest, halves away from zero). */
|
|
266
311
|
altitude(feet: number): Self {
|
|
267
312
|
this.fields.altitudeFeet = feet;
|
|
313
|
+
this.altitudeUnit = 'feet';
|
|
268
314
|
return this.self();
|
|
269
315
|
}
|
|
270
316
|
|
|
271
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* Altitude, metres: converted to feet (0.3048 m each) and rounded once for a `/A=`; a Mic-E
|
|
319
|
+
* report sends it in its own altitude bytes, whose unit is the metre, rounded to the metre.
|
|
320
|
+
*/
|
|
272
321
|
altitudeMetres(metres: number): Self {
|
|
273
|
-
this.fields.altitudeFeet =
|
|
322
|
+
this.fields.altitudeFeet = metres / 0.3048;
|
|
323
|
+
this.altitudeUnit = 'metres';
|
|
274
324
|
return this.self();
|
|
275
325
|
}
|
|
276
326
|
|
|
327
|
+
/** The unit the altitude was given in. */
|
|
328
|
+
protected altitudeUnit: 'feet' | 'metres' | undefined;
|
|
329
|
+
|
|
277
330
|
/** Free text. */
|
|
278
331
|
comment(text: string): Self {
|
|
279
332
|
this.fields.comment = text;
|
|
@@ -324,8 +377,8 @@ abstract class ReportBuilder<Self> extends PositionedBuilder<Self> {
|
|
|
324
377
|
}
|
|
325
378
|
|
|
326
379
|
/** Weather data (needs a weather station symbol, `_`). */
|
|
327
|
-
weather(weather:
|
|
328
|
-
this.fields.weather = weather;
|
|
380
|
+
weather(weather: WeatherInput): Self {
|
|
381
|
+
this.fields.weather = weatherOf(weather);
|
|
329
382
|
return this.self();
|
|
330
383
|
}
|
|
331
384
|
|
|
@@ -487,6 +540,11 @@ export class MicEBuilder extends PositionedBuilder<MicEBuilder> {
|
|
|
487
540
|
|
|
488
541
|
build(): BuiltPacket {
|
|
489
542
|
const data: Mutable<Extract<AprsData, { type: 'mic-e' }>> = { type: 'mic-e', micEMessage: this.messageValue, ...this.fields };
|
|
543
|
+
// A course of 0 (after rounding) is unknown, which Mic-E writes as 0: no course (B1).
|
|
544
|
+
if (data.courseDegrees !== undefined && Number.isFinite(data.courseDegrees) && nearest(data.courseDegrees) === 0) delete data.courseDegrees;
|
|
545
|
+
// Metres go in the Mic-E altitude bytes, to the metre; feet in a /A= (B2).
|
|
546
|
+
if (data.altitudeFeet !== undefined && this.altitudeUnit === 'metres') data.altitudeFeet = nearest(data.altitudeFeet * 0.3048) / 0.3048;
|
|
547
|
+
if (data.altitudeFeet !== undefined && this.altitudeUnit === 'feet') ALTITUDE_GIVEN_IN_FEET.add(data);
|
|
490
548
|
if (this.typeCodeValue !== undefined) data.typeCode = this.typeCodeValue;
|
|
491
549
|
if (this.locatorValue !== undefined) data.locator = this.locatorValue;
|
|
492
550
|
if (this.oldValue) data.oldData = true;
|
|
@@ -589,7 +647,7 @@ export class TelemetryBuilder {
|
|
|
589
647
|
|
|
590
648
|
constructor(
|
|
591
649
|
private readonly header: Header,
|
|
592
|
-
private readonly sequence: string,
|
|
650
|
+
private readonly sequence: number | string,
|
|
593
651
|
private readonly analog: readonly (number | null)[],
|
|
594
652
|
private readonly bits: string,
|
|
595
653
|
) {}
|
|
@@ -601,9 +659,14 @@ export class TelemetryBuilder {
|
|
|
601
659
|
}
|
|
602
660
|
|
|
603
661
|
build(): BuiltPacket {
|
|
662
|
+
let sequence = this.sequence;
|
|
663
|
+
if (typeof sequence === 'number') {
|
|
664
|
+
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 999) refuse(`telemetry sequence ${sequence} is not 0-999`);
|
|
665
|
+
sequence = String(sequence).padStart(3, '0');
|
|
666
|
+
}
|
|
604
667
|
const data: Mutable<Extract<AprsData, { type: 'telemetry' }>> = {
|
|
605
668
|
type: 'telemetry',
|
|
606
|
-
sequence
|
|
669
|
+
sequence,
|
|
607
670
|
analog: [...this.analog],
|
|
608
671
|
bits: this.bits,
|
|
609
672
|
};
|
package/src/decode/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { binaryToBytes, bytesToBinary } from '../bytes.js';
|
|
4
4
|
import type { Diagnostic, ParseOptions } from '../diagnostics.js';
|
|
5
|
+
import { identifyDevice } from '../deviceid.js';
|
|
5
6
|
import { parseHeaderText } from '../header.js';
|
|
6
7
|
import type { AprsData, AprsPacket, ThirdParty } from '../types.js';
|
|
7
8
|
import { DecodeContext, Rejected } from './context.js';
|
|
@@ -128,6 +129,9 @@ function decodeThirdParty(ctx: DecodeContext, s: string, options: ParseOptions):
|
|
|
128
129
|
data: inner.data,
|
|
129
130
|
diagnostics: [...headerDiags, ...inner.diagnostics],
|
|
130
131
|
};
|
|
132
|
+
// The inner packet has its device too, from its own destination (vectors rulings, A2).
|
|
133
|
+
const device = identifyDevice(inner.data, header.destination);
|
|
134
|
+
if (device) packet.device = device;
|
|
131
135
|
// A q-construct is read only in the outer header: the third-party path is kept as sent.
|
|
132
136
|
return { type: 'third-party', packet };
|
|
133
137
|
}
|
package/src/decode/other.ts
CHANGED
|
@@ -150,7 +150,7 @@ export function decodeQuery(ctx: DecodeContext, s: string): Query {
|
|
|
150
150
|
const longitude = Number(f[2]);
|
|
151
151
|
// A footprint is a real place (vectors interpretations.md).
|
|
152
152
|
if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) ctx.fail('invalid-general-query');
|
|
153
|
-
out.footprint = { latitude, longitude, radiusMiles: Number(f[3]) };
|
|
153
|
+
out.footprint = { latitude, longitude, radiusMiles: Number(f[3]), latitudeText: f[1]!, longitudeText: f[2]! };
|
|
154
154
|
}
|
|
155
155
|
return out;
|
|
156
156
|
}
|
package/src/decode/positioned.ts
CHANGED
|
@@ -618,6 +618,15 @@ interface LiftOptions {
|
|
|
618
618
|
symbol: AprsSymbol;
|
|
619
619
|
/** An area object's line shape, whose corridor width may be in braces. */
|
|
620
620
|
areaLine?: boolean;
|
|
621
|
+
/** Telemetry and the `!DAO!`, already lifted out of the text (Mic-E status text). */
|
|
622
|
+
pre?: PreLifted;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Base-91 telemetry and a `!DAO!` lifted out of text, and the text left. */
|
|
626
|
+
export interface PreLifted {
|
|
627
|
+
telemetry?: CommentTelemetry;
|
|
628
|
+
dao?: DaoFound;
|
|
629
|
+
rest: string;
|
|
621
630
|
}
|
|
622
631
|
|
|
623
632
|
/** What the comment lifting found. */
|
|
@@ -650,10 +659,10 @@ function removeRanges(s: string, ranges: readonly [number, number][]): string {
|
|
|
650
659
|
* Lifts base-91 telemetry and the last `!DAO!` outside it. A `!DAO!` is five bytes as sent, so one
|
|
651
660
|
* is never read across a `join`, a place in `s` where another element was already taken out.
|
|
652
661
|
*/
|
|
653
|
-
function liftTelemetryAndDao(
|
|
662
|
+
export function liftTelemetryAndDao(
|
|
654
663
|
s: string,
|
|
655
664
|
joins: readonly number[] = [],
|
|
656
|
-
): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string } {
|
|
665
|
+
): { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string; cuts: number[] } {
|
|
657
666
|
const tel = findCommentTelemetry(s);
|
|
658
667
|
let dao: DaoFound | undefined;
|
|
659
668
|
for (let p = s.length - 5; p >= 0; p--) {
|
|
@@ -668,7 +677,14 @@ function liftTelemetryAndDao(
|
|
|
668
677
|
const ranges: [number, number][] = [];
|
|
669
678
|
if (tel) ranges.push([tel.start, tel.end]);
|
|
670
679
|
if (dao) ranges.push([dao.start, dao.start + 5]);
|
|
671
|
-
|
|
680
|
+
// Where, in the text left, something was taken out.
|
|
681
|
+
let removed = 0;
|
|
682
|
+
const cuts: number[] = [];
|
|
683
|
+
for (const [a, b] of [...ranges].sort((x, y) => x[0] - y[0])) {
|
|
684
|
+
cuts.push(a - removed);
|
|
685
|
+
removed += b - a;
|
|
686
|
+
}
|
|
687
|
+
const out: { telemetry?: CommentTelemetry; dao?: DaoFound; rest: string; cuts: number[] } = { rest: removeRanges(s, ranges), cuts };
|
|
672
688
|
if (tel) out.telemetry = tel.telemetry;
|
|
673
689
|
if (dao) out.dao = dao;
|
|
674
690
|
return out;
|
|
@@ -678,7 +694,7 @@ const ALTITUDE_RE = /\/A=(-[0-9]{5}|[0-9]{6})/;
|
|
|
678
694
|
|
|
679
695
|
/** Lifts the structured elements out of a comment, in the order the vectors' README gives. */
|
|
680
696
|
function liftComment(ctx: DecodeContext, text: string, options: LiftOptions): Lifted {
|
|
681
|
-
const first = liftTelemetryAndDao(text, options.joins);
|
|
697
|
+
const first = options.pre ? { ...options.pre, rest: text } : liftTelemetryAndDao(text, options.joins);
|
|
682
698
|
const lifted: Lifted = { rest: first.rest };
|
|
683
699
|
if (first.telemetry) lifted.telemetry = first.telemetry;
|
|
684
700
|
if (first.dao) lifted.dao = first.dao;
|
|
@@ -857,6 +873,12 @@ function parseWeatherFields(ctx: DecodeContext, s: string, j: number, options: W
|
|
|
857
873
|
j += 4;
|
|
858
874
|
continue;
|
|
859
875
|
}
|
|
876
|
+
// Exactly three spaces is a snowfall sent as unknown, as for any other field.
|
|
877
|
+
if (v === ' ') {
|
|
878
|
+
seen.add(key);
|
|
879
|
+
j += 4;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
860
882
|
// Only a run of dots may be shorter than three, and only when no digit follows it: s..6 is
|
|
861
883
|
// not a field, so the fields end there.
|
|
862
884
|
const dots = /^\.{1,3}/.exec(v)?.[0];
|
|
@@ -961,7 +983,7 @@ function weatherTail(
|
|
|
961
983
|
rest: string,
|
|
962
984
|
positioned: boolean,
|
|
963
985
|
): { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } {
|
|
964
|
-
const lifted = positioned ? liftTelemetryAndDao(rest) : { rest };
|
|
986
|
+
const lifted: PreLifted = positioned ? liftTelemetryAndDao(rest) : { rest };
|
|
965
987
|
const out: { comment?: string; telemetry?: CommentTelemetry; dao?: DaoFound } = {};
|
|
966
988
|
if (lifted.telemetry) out.telemetry = lifted.telemetry;
|
|
967
989
|
if (lifted.dao) out.dao = lifted.dao;
|
|
@@ -1118,7 +1140,8 @@ function applyLifted(ctx: DecodeContext, f: Fields, lifted: Lifted): void {
|
|
|
1118
1140
|
const STORM_TYPES: Record<string, StormType> = { TS: 'tropical-storm', HC: 'hurricane', TD: 'tropical-depression' };
|
|
1119
1141
|
|
|
1120
1142
|
function parseStorm(s: string): { storm: Storm; length: number } | undefined {
|
|
1121
|
-
|
|
1143
|
+
// Each field is digits (vectors object-item/storm-data-with-a-space-in-a-field-is-text).
|
|
1144
|
+
const m = /^\/(TS|HC|TD)\/([0-9]{3})\^([0-9]{3})\/([0-9]{4})>([0-9]{3})&([0-9]{3})(?:%([0-9]{3}))?/.exec(s);
|
|
1122
1145
|
if (!m) return undefined;
|
|
1123
1146
|
const num = (v: string | undefined): number | undefined => (v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined);
|
|
1124
1147
|
const storm: { -readonly [K in keyof Storm]: Storm[K] } = { type: STORM_TYPES[m[1]!]! };
|
|
@@ -1190,11 +1213,12 @@ export function liftMicEComment(
|
|
|
1190
1213
|
text: string,
|
|
1191
1214
|
symbol: AprsSymbol,
|
|
1192
1215
|
extension: ExtensionFound | undefined,
|
|
1193
|
-
|
|
1216
|
+
pre: PreLifted,
|
|
1217
|
+
atStart = true,
|
|
1194
1218
|
): { lifted: Lifted } {
|
|
1195
1219
|
const options: LiftOptions = extension
|
|
1196
|
-
? { extensionFound: true, extension, symbol,
|
|
1197
|
-
: { extensionFound: false, extensionAtStart:
|
|
1220
|
+
? { extensionFound: true, extension, symbol, pre }
|
|
1221
|
+
: { extensionFound: false, extensionAtStart: atStart, symbol, pre };
|
|
1198
1222
|
return { lifted: liftComment(ctx, text, options) };
|
|
1199
1223
|
}
|
|
1200
1224
|
|
package/src/decode/reports.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isDigit,
|
|
21
21
|
isSymbolTable,
|
|
22
22
|
liftMicEComment,
|
|
23
|
+
liftTelemetryAndDao,
|
|
23
24
|
METRES_PER_FOOT,
|
|
24
25
|
parsePosition,
|
|
25
26
|
parsePositionlessWeather,
|
|
@@ -257,6 +258,8 @@ function micEMinutes(tens: number, units: number, hundredths: number, ambiguity:
|
|
|
257
258
|
|
|
258
259
|
const TYPE_CODES = '`\'>] ';
|
|
259
260
|
|
|
261
|
+
const MIC_E_LOCATOR = /^([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\/G/;
|
|
262
|
+
|
|
260
263
|
/** `` ` ``, `'`, 0x1c, 0x1d: a Mic-E report. */
|
|
261
264
|
export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
262
265
|
// The Rev 0 beta data type identifiers are obsolete: said at the DTI, before anything is checked.
|
|
@@ -348,6 +351,7 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
348
351
|
}
|
|
349
352
|
let altitudeFeet: number | undefined;
|
|
350
353
|
let extension: ExtensionFound | undefined;
|
|
354
|
+
let extensionAfterLocator = false;
|
|
351
355
|
// Where a later altitude was taken out of the text: a !DAO! is never read across it.
|
|
352
356
|
let join: number | undefined;
|
|
353
357
|
const altFirst = /^([!-{]{3})\}/.exec(text);
|
|
@@ -355,32 +359,52 @@ export function decodeMicE(ctx: DecodeContext, s: string): MicEReport {
|
|
|
355
359
|
altitudeFeet = (base91Value(altFirst[1]!) - 10000) / METRES_PER_FOOT;
|
|
356
360
|
text = text.slice(4);
|
|
357
361
|
} else {
|
|
358
|
-
// A data extension straight after the type code
|
|
359
|
-
// for an altitude later in the text (`0PH}` in
|
|
360
|
-
|
|
361
|
-
|
|
362
|
+
// A data extension straight after the type code, or after a locator there and its space, is
|
|
363
|
+
// read first, so its bytes are never taken for an altitude later in the text (`0PH}` in
|
|
364
|
+
// `PHG3330PH}`).
|
|
365
|
+
const loc = MIC_E_LOCATOR.exec(text);
|
|
366
|
+
const at = loc && text[loc[0].length] === ' ' ? loc[0].length + 1 : 0;
|
|
367
|
+
extension = extensionAt(text, at);
|
|
368
|
+
if (extension) {
|
|
369
|
+
text = text.slice(0, at) + text.slice(at + extension.length);
|
|
370
|
+
extensionAfterLocator = at > 0;
|
|
371
|
+
}
|
|
362
372
|
const later = /([!-{]{3})\}/.exec(text);
|
|
363
373
|
if (later && ctx.allows('mic-e-altitude-not-first')) {
|
|
364
374
|
altitudeFeet = (base91Value(later[1]!) - 10000) / METRES_PER_FOOT;
|
|
365
375
|
text = text.slice(0, later.index) + text.slice(later.index + 4);
|
|
366
376
|
join = later.index;
|
|
377
|
+
} else if (!ctx.options.tolerates('mic-e-altitude-not-first')) {
|
|
378
|
+
// A later altitude declined is left as text; lifting telemetry or the !DAO! must then not
|
|
379
|
+
// join the bytes either side of its place into another one. With no later altitude, strict reads as lenient (vectors
|
|
380
|
+
// README, Mic-E).
|
|
381
|
+
const lifted = liftTelemetryAndDao(text);
|
|
382
|
+
const across = [...lifted.rest.matchAll(/(?=([!-{]{3})\})/g)].some((m) => lifted.cuts.some((c) => c > m.index && c < m.index + 4));
|
|
383
|
+
if (later && across) ctx.fail('mic-e-altitude-not-first');
|
|
367
384
|
}
|
|
368
385
|
}
|
|
369
386
|
if (altitudeFeet !== undefined) f.altitudeFeet = altitudeFeet;
|
|
387
|
+
// Base-91 telemetry and the !DAO! are lifted before the locator is read, so a locator followed
|
|
388
|
+
// straight by either has no missing space (vectors rulings, F16).
|
|
389
|
+
const joins = join !== undefined && join > 0 ? [join] : [];
|
|
390
|
+
const pre = liftTelemetryAndDao(text, joins);
|
|
391
|
+
text = pre.rest;
|
|
392
|
+
// A locator is read only at the start of the status text, after any type code or a Mic-E
|
|
393
|
+
// altitude there; after a PHG or RNG, say, it is text (vectors rulings, F17).
|
|
370
394
|
let locator: string | undefined;
|
|
371
|
-
const loc =
|
|
395
|
+
const loc = extension && !extensionAfterLocator ? null : MIC_E_LOCATOR.exec(text);
|
|
372
396
|
if (loc) {
|
|
373
397
|
locator = loc[1]!.toUpperCase();
|
|
374
|
-
const before = text.length;
|
|
375
398
|
text = text.slice(loc[0].length);
|
|
376
399
|
if (text.length > 0) {
|
|
377
400
|
if (text[0] === ' ') text = text.slice(1);
|
|
378
401
|
else ctx.tolerate('missing-space-after-locator');
|
|
379
402
|
}
|
|
380
|
-
if (join !== undefined) join -= before - text.length;
|
|
381
403
|
}
|
|
382
|
-
|
|
383
|
-
|
|
404
|
+
// A PHG or RNG is straight after the type code only where it is so as sent: one that reaches
|
|
405
|
+
// there once a later altitude is lifted is late (vectors mic-e/an-rng-reached-only-by-lifting-an-altitude-is-late).
|
|
406
|
+
const startJoined = !extension && !loc && join !== undefined && (extensionAt(text, 0)?.length ?? 0) > join;
|
|
407
|
+
const { lifted } = liftMicEComment(ctx, text, pos.symbol, extension, pre, !startJoined);
|
|
384
408
|
// The !DAO! is structure, so it is judged before the text's encoding.
|
|
385
409
|
applyDao(ctx, f, pos, lifted.dao);
|
|
386
410
|
applyLiftedTo(ctx, f, lifted);
|
package/src/decoder.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { encodeUtf8 } from './bytes.js';
|
|
4
4
|
import { decodeInformation } from './decode/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import { identifyDevice } from './deviceid.js';
|
|
6
6
|
import { ParseOptions } from './diagnostics.js';
|
|
7
7
|
import { findQConstruct, parseAx25Frame, parseTnc2Header, type ParsedHeader } from './header.js';
|
|
8
8
|
import type { AprsPacket } from './types.js';
|
|
@@ -19,10 +19,7 @@ function assemble(header: ParsedHeader, options: ParseOptions): AprsPacket {
|
|
|
19
19
|
};
|
|
20
20
|
const q = findQConstruct(header.path);
|
|
21
21
|
if (q) packet.qConstruct = q;
|
|
22
|
-
const device =
|
|
23
|
-
decoded.data.type === 'mic-e'
|
|
24
|
-
? identifyMicE(decoded.data.typeCode, decoded.data.deviceSuffix)
|
|
25
|
-
: identifyTocall(header.destination);
|
|
22
|
+
const device = identifyDevice(decoded.data, header.destination);
|
|
26
23
|
if (device) packet.device = device;
|
|
27
24
|
return packet;
|
|
28
25
|
}
|
package/src/deviceid.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// its type code and suffix.
|
|
4
4
|
|
|
5
5
|
import { MICE, MICE_LEGACY, TOCALLS, type DeviceEntry } from './deviceid-data.js';
|
|
6
|
-
import type { DeviceInfo } from './types.js';
|
|
6
|
+
import type { AprsData, DeviceInfo } from './types.js';
|
|
7
7
|
|
|
8
8
|
export { DEVICEID_SOURCE, DEVICE_CLASSES } from './deviceid-data.js';
|
|
9
9
|
|
|
@@ -53,9 +53,13 @@ function compile(): CompiledTocall[] {
|
|
|
53
53
|
const tocallCache = new Map<string, DeviceInfo | undefined>();
|
|
54
54
|
const TOCALL_CACHE_LIMIT = 4096;
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. The lookup is
|
|
58
|
+
* case-sensitive: the database lists tocalls in upper case and a destination is a callsign, so
|
|
59
|
+
* `APNl02` matches nothing (vectors rulings, A1).
|
|
60
|
+
*/
|
|
57
61
|
export function identifyTocall(destination: string): DeviceInfo | undefined {
|
|
58
|
-
const call = destination.split('-')[0]
|
|
62
|
+
const call = destination.split('-')[0]!;
|
|
59
63
|
if (tocallCache.has(call)) return tocallCache.get(call);
|
|
60
64
|
let found: DeviceInfo | undefined;
|
|
61
65
|
for (const t of compile()) {
|
|
@@ -96,3 +100,8 @@ export function identifyMicE(typeCode: string | undefined, suffix: string | unde
|
|
|
96
100
|
}
|
|
97
101
|
return undefined;
|
|
98
102
|
}
|
|
103
|
+
|
|
104
|
+
/** The device a packet identifies: by its Mic-E type code and suffix, else by its destination. */
|
|
105
|
+
export function identifyDevice(data: AprsData, destination: string): DeviceInfo | undefined {
|
|
106
|
+
return data.type === 'mic-e' ? identifyMicE(data.typeCode, data.deviceSuffix) : identifyTocall(destination);
|
|
107
|
+
}
|
package/src/encode/common.ts
CHANGED
|
@@ -19,10 +19,26 @@ export function pad(n: number, width: number): string {
|
|
|
19
19
|
return String(n).padStart(width, '0');
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The nearest whole number, halves away from zero, as the vectors' rules round wherever they say
|
|
24
|
+
* "nearest" (`Math.round` takes a half towards +infinity, so -2.5 to -2). A value within 1e-6 of a
|
|
25
|
+
* half is the half: decimal text such as 58.585 is seldom exactly that in binary, and the half the
|
|
26
|
+
* text means wins (vectors rulings, F2).
|
|
27
|
+
*/
|
|
28
|
+
export function nearest(value: number): number {
|
|
29
|
+
const abs = Math.abs(value);
|
|
30
|
+
const below = Math.floor(abs);
|
|
31
|
+
const n = abs - below >= 0.5 - HALF_TOLERANCE ? below + 1 : below;
|
|
32
|
+
return value < 0 ? -n : n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How near a half, in steps, a value is taken for the half. */
|
|
36
|
+
const HALF_TOLERANCE = 1e-6;
|
|
37
|
+
|
|
38
|
+
/** A whole number within [min, max], else refused; a fraction is rounded to the nearest. */
|
|
23
39
|
export function whole(value: number, min: number, max: number, what: string): number {
|
|
24
40
|
if (!Number.isFinite(value)) refuse(`${what} is not a number`);
|
|
25
|
-
const n =
|
|
41
|
+
const n = nearest(value);
|
|
26
42
|
if (n < min || n > max) refuse(`${what} ${value} is out of range (${min}-${max})`);
|
|
27
43
|
return n;
|
|
28
44
|
}
|
|
@@ -80,8 +96,22 @@ export function base91(value: number, width: number): string {
|
|
|
80
96
|
return out;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* A number as plain decimal text: the shortest that reads back as the same number, with no
|
|
101
|
+
* exponent and no `+`, a `0` before a leading point and no trailing `.0` (`0.53`, `-94`, 1e61 as
|
|
102
|
+
* `1` and 61 zeros, 1e-7 as `0.0000001`; vectors rulings, F11).
|
|
103
|
+
*/
|
|
84
104
|
export function numberText(v: number): string {
|
|
85
105
|
if (!Number.isFinite(v)) refuse('a value is not a number');
|
|
86
|
-
|
|
106
|
+
if (Object.is(v, -0)) return '0';
|
|
107
|
+
const shortest = String(v);
|
|
108
|
+
const m = /^(-?)([0-9])(?:\.([0-9]+))?e([+-][0-9]+)$/.exec(shortest);
|
|
109
|
+
if (!m) return shortest;
|
|
110
|
+
const sign = m[1]!;
|
|
111
|
+
const digits = m[2]! + (m[3] ?? '');
|
|
112
|
+
// The point sits after the first digit; the exponent moves it.
|
|
113
|
+
const point = 1 + Number(m[4]);
|
|
114
|
+
if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`;
|
|
115
|
+
if (point >= digits.length) return sign + digits + '0'.repeat(point - digits.length);
|
|
116
|
+
return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
87
117
|
}
|
package/src/encode/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The encoder: writes only what the spec allows and refuses anything else with a reason. Free
|
|
2
2
|
// text is checked by decoding what was written: if the result does not read back as the same
|
|
3
|
-
// data (a comment that would read as an altitude, say),
|
|
4
|
-
//
|
|
3
|
+
// data (a comment that would read as an altitude, say), the comment is tried straight after a
|
|
4
|
+
// frequency, then after a `/` delimiter, and if neither helps the encoder refuses.
|
|
5
5
|
|
|
6
6
|
import { binaryToBytes, bytesToBinary, decodeUtf8 } from '../bytes.js';
|
|
7
7
|
import { decodeInformation } from '../decode/index.js';
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
encodeThirdParty,
|
|
31
31
|
encodeUserDefined,
|
|
32
32
|
} from './other.js';
|
|
33
|
-
import { encodeItem, encodeMicE, encodeObject, encodePositionReport, encodeWeatherReport, type CommentMode } from './position.js';
|
|
33
|
+
import { DEFAULT_COMPRESSION, encodeItem, encodeMicE, encodeObject, encodePositionReport, encodeWeatherReport, type CommentMode } from './position.js';
|
|
34
34
|
|
|
35
35
|
export { AprsEncodeError } from './common.js';
|
|
36
36
|
|
|
@@ -156,14 +156,19 @@ function expected(data: AprsData, a: Attempt): AprsData {
|
|
|
156
156
|
let d = data;
|
|
157
157
|
if ((d.type === 'position' || d.type === 'object' || d.type === 'item') && d.compressed && a.compression) {
|
|
158
158
|
if (!d.compression) d = { ...d, compression: a.compression };
|
|
159
|
-
if (d.courseDegrees !== undefined && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
|
|
160
159
|
}
|
|
161
|
-
if (d.type === 'mic-e') {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
if (d.type === 'mic-e' && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
|
|
161
|
+
if (d.type === 'third-party' && d.packet.information.length === 0) {
|
|
162
|
+
// An inner packet written from its data: cs values with no compression type got the default.
|
|
163
|
+
const inner = d.packet.data;
|
|
164
|
+
if ((inner.type === 'position' || inner.type === 'object' || inner.type === 'item') && inner.compressed && !inner.compression) {
|
|
165
|
+
const w = inner.weather;
|
|
166
|
+
const carries =
|
|
167
|
+
inner.courseDegrees !== undefined ||
|
|
168
|
+
inner.speedKnots !== undefined ||
|
|
169
|
+
inner.rangeMiles !== undefined ||
|
|
170
|
+
(w !== undefined && (w.windDirectionDegrees !== undefined || w.windSpeedMph !== undefined));
|
|
171
|
+
if (carries) d = { ...d, packet: { ...d.packet, data: { ...inner, compression: DEFAULT_COMPRESSION } } };
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
return d;
|
|
@@ -179,10 +184,24 @@ function check(data: AprsData, a: Attempt): string[] {
|
|
|
179
184
|
return problems;
|
|
180
185
|
}
|
|
181
186
|
// An NMEA sentence is written as it is, so everything read from it must be what the data says.
|
|
182
|
-
shapeDifferences(toNeutralData(decoded.data), toNeutralData(expected(data, a)), '', problems, data.type === 'nmea');
|
|
187
|
+
shapeDifferences(withoutInnerInfo(toNeutralData(decoded.data)), withoutInnerInfo(toNeutralData(expected(data, a))), '', problems, data.type === 'nmea');
|
|
183
188
|
return problems;
|
|
184
189
|
}
|
|
185
190
|
|
|
191
|
+
/**
|
|
192
|
+
* A third-party packet's neutral data without its inner packet's `info` diagnostics: an info is
|
|
193
|
+
* not a defect, so an inner packet that reads back with only those is written (vectors rulings, F10).
|
|
194
|
+
*/
|
|
195
|
+
function withoutInnerInfo(n: Record<string, unknown>): Record<string, unknown> {
|
|
196
|
+
const packet = n.packet as Record<string, unknown> | undefined;
|
|
197
|
+
if (n.type !== 'third-party' || !packet || !Array.isArray(packet.diagnostics)) return n;
|
|
198
|
+
const kept = (packet.diagnostics as string[]).filter((d) => !d.startsWith('info:'));
|
|
199
|
+
const copy: Record<string, unknown> = { ...packet };
|
|
200
|
+
if (kept.length > 0) copy.diagnostics = kept;
|
|
201
|
+
else delete copy.diagnostics;
|
|
202
|
+
return { ...n, packet: copy };
|
|
203
|
+
}
|
|
204
|
+
|
|
186
205
|
/**
|
|
187
206
|
* Encodes data as an information field. Mic-E also gives the destination address it computed.
|
|
188
207
|
* Throws `AprsEncodeError`, with the reason, for anything the spec does not allow.
|
|
@@ -192,9 +211,9 @@ export function encodeInformation(data: AprsData): EncodedInformation {
|
|
|
192
211
|
let problems = check(data, first);
|
|
193
212
|
let chosen = first;
|
|
194
213
|
if (problems.length > 0 && typeof (data as { comment?: unknown }).comment === 'string') {
|
|
195
|
-
// The comment would read as something else: try it straight after a frequency, then after a
|
|
196
|
-
// then after
|
|
197
|
-
for (const mode of ['joined', 'delimited', '
|
|
214
|
+
// The comment would read as something else: try it straight after a frequency, then after a
|
|
215
|
+
// /, then after a space.
|
|
216
|
+
for (const mode of ['joined', 'delimited', 'spaced'] as const) {
|
|
198
217
|
let next: Attempt | undefined;
|
|
199
218
|
try {
|
|
200
219
|
next = attempt(data, mode);
|
package/src/encode/other.ts
CHANGED
|
@@ -209,12 +209,22 @@ export function encodeQuery(d: Query): string {
|
|
|
209
209
|
if (d.footprint) {
|
|
210
210
|
const f = d.footprint;
|
|
211
211
|
if (!(f.latitude >= -90 && f.latitude <= 90) || !(f.longitude >= -180 && f.longitude <= 180)) refuse('the footprint is out of range');
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
// Each value as sent, a leading space or its absence included, when that is the value (vectors
|
|
213
|
+
// rulings, E1); otherwise one that is not negative, 0 included, after a space (APRS12c ch. 15;
|
|
214
|
+
// vectors rulings, F11, F15).
|
|
215
|
+
const asSent = (text: string | undefined, v: number): string | undefined =>
|
|
216
|
+
text !== undefined && FOOTPRINT_VALUE.test(text) && Number(text) === v ? text : undefined;
|
|
217
|
+
const value = (v: number): string => (v < 0 ? numberText(v) : ` ${numberText(v)}`);
|
|
218
|
+
const lat = asSent(f.latitudeText, f.latitude) ?? value(f.latitude);
|
|
219
|
+
const lon = asSent(f.longitudeText, f.longitude) ?? value(f.longitude);
|
|
220
|
+
out += `${lat},${lon},${pad(whole(f.radiusMiles, 0, 9999, 'radius'), 4)}`;
|
|
214
221
|
}
|
|
215
222
|
return out;
|
|
216
223
|
}
|
|
217
224
|
|
|
225
|
+
/** A footprint value as a decoder reads it: a number as a telemetry value is, a positive one after at most one space. */
|
|
226
|
+
const FOOTPRINT_VALUE = /^(?: ?(?:[0-9]+\.?[0-9]*|\.[0-9]+)|-(?:[0-9]+\.?[0-9]*|\.[0-9]+))$/;
|
|
227
|
+
|
|
218
228
|
/** A control character: below U+0020, or U+007F. */
|
|
219
229
|
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
220
230
|
|