@packet-net/pdn-aprs 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +9 -5
- package/dist/builder.d.ts +33 -8
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +64 -10
- package/dist/builder.js.map +1 -1
- package/dist/bytes.d.ts.map +1 -1
- package/dist/bytes.js +2 -1
- package/dist/bytes.js.map +1 -1
- package/dist/codes.js +8 -8
- package/dist/codes.js.map +1 -1
- package/dist/decode/index.d.ts.map +1 -1
- package/dist/decode/index.js +8 -5
- package/dist/decode/index.js.map +1 -1
- package/dist/decode/message.d.ts +2 -0
- package/dist/decode/message.d.ts.map +1 -1
- package/dist/decode/message.js +59 -33
- package/dist/decode/message.js.map +1 -1
- package/dist/decode/other.d.ts +20 -2
- package/dist/decode/other.d.ts.map +1 -1
- package/dist/decode/other.js +110 -48
- package/dist/decode/other.js.map +1 -1
- package/dist/decode/positioned.d.ts +26 -2
- package/dist/decode/positioned.d.ts.map +1 -1
- package/dist/decode/positioned.js +145 -79
- package/dist/decode/positioned.js.map +1 -1
- package/dist/decode/reports.d.ts.map +1 -1
- package/dist/decode/reports.js +47 -5
- package/dist/decode/reports.js.map +1 -1
- package/dist/decoder.d.ts.map +1 -1
- package/dist/decoder.js +2 -4
- package/dist/decoder.js.map +1 -1
- package/dist/deviceid.d.ts +8 -2
- package/dist/deviceid.d.ts.map +1 -1
- package/dist/deviceid.js +28 -6
- package/dist/deviceid.js.map +1 -1
- package/dist/encode/common.d.ts +13 -2
- package/dist/encode/common.d.ts.map +1 -1
- package/dist/encode/common.js +36 -4
- package/dist/encode/common.js.map +1 -1
- package/dist/encode/index.d.ts.map +1 -1
- package/dist/encode/index.js +59 -22
- package/dist/encode/index.js.map +1 -1
- package/dist/encode/other.d.ts.map +1 -1
- package/dist/encode/other.js +45 -17
- package/dist/encode/other.js.map +1 -1
- package/dist/encode/position.d.ts +9 -3
- package/dist/encode/position.d.ts.map +1 -1
- package/dist/encode/position.js +258 -124
- package/dist/encode/position.js.map +1 -1
- package/dist/header.d.ts +5 -2
- package/dist/header.d.ts.map +1 -1
- package/dist/header.js +8 -3
- package/dist/header.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/neutral.d.ts.map +1 -1
- package/dist/neutral.js +22 -3
- package/dist/neutral.js.map +1 -1
- package/dist/types.d.ts +19 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/builder.ts +77 -14
- package/src/bytes.ts +2 -1
- package/src/codes.ts +8 -8
- package/src/decode/index.ts +7 -4
- package/src/decode/message.ts +54 -37
- package/src/decode/other.ts +116 -44
- package/src/decode/positioned.ts +159 -90
- package/src/decode/reports.ts +47 -4
- package/src/decoder.ts +2 -5
- package/src/deviceid.ts +29 -5
- package/src/encode/common.ts +34 -4
- package/src/encode/index.ts +57 -20
- package/src/encode/other.ts +39 -13
- package/src/encode/position.ts +261 -119
- package/src/header.ts +9 -2
- package/src/index.ts +1 -0
- package/src/neutral.ts +24 -3
- package/src/types.ts +20 -5
package/src/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
|
|
|
@@ -47,11 +47,30 @@ function compile(): CompiledTocall[] {
|
|
|
47
47
|
return compiled;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Destinations repeat constantly (a handful of tocalls cover most traffic), and matching one means
|
|
51
|
+
// trying up to every pattern in the database, so answers are remembered. The cache is bounded so a
|
|
52
|
+
// stream of junk destinations cannot grow it without limit.
|
|
53
|
+
const tocallCache = new Map<string, DeviceInfo | undefined>();
|
|
54
|
+
const TOCALL_CACHE_LIMIT = 4096;
|
|
55
|
+
|
|
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
|
+
*/
|
|
51
61
|
export function identifyTocall(destination: string): DeviceInfo | undefined {
|
|
52
|
-
const call = destination.split('-')[0]
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
const call = destination.split('-')[0]!;
|
|
63
|
+
if (tocallCache.has(call)) return tocallCache.get(call);
|
|
64
|
+
let found: DeviceInfo | undefined;
|
|
65
|
+
for (const t of compile()) {
|
|
66
|
+
if (t.regex.test(call)) {
|
|
67
|
+
found = Object.freeze(toInfo(t.entry));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (tocallCache.size >= TOCALL_CACHE_LIMIT) tocallCache.clear();
|
|
72
|
+
tocallCache.set(call, found);
|
|
73
|
+
return found;
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
/**
|
|
@@ -81,3 +100,8 @@ export function identifyMicE(typeCode: string | undefined, suffix: string | unde
|
|
|
81
100
|
}
|
|
82
101
|
return undefined;
|
|
83
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
|
|
|
@@ -99,9 +99,12 @@ function attempt(data: AprsData, mode: CommentMode): Attempt {
|
|
|
99
99
|
return { binary: encodeCapabilities(data) };
|
|
100
100
|
case 'third-party': {
|
|
101
101
|
// The original information field must not be changed (APRS12c ch. 17).
|
|
102
|
+
// An inner packet built by hand, with no information field, is encoded from its data.
|
|
102
103
|
const original = data.packet.information;
|
|
103
|
-
const inner =
|
|
104
|
-
|
|
104
|
+
const inner = data.packet.data;
|
|
105
|
+
const asReceived = original.length > 0 || (inner.type === 'unrecognized' && inner.reason === 'empty');
|
|
106
|
+
const info = asReceived ? original : encodeInformation(inner).info;
|
|
107
|
+
return { binary: encodeThirdParty(data, bytesToBinary(info)) };
|
|
105
108
|
}
|
|
106
109
|
case 'user-defined':
|
|
107
110
|
return { binary: encodeUserDefined(data) };
|
|
@@ -114,12 +117,25 @@ function attempt(data: AprsData, mode: CommentMode): Attempt {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
/**
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
+
/** Numbers that agree within 1e-9, relative to the larger (absolute below 1). */
|
|
121
|
+
function sameNumber(a: number, b: number): boolean {
|
|
122
|
+
const scale = Math.max(Math.abs(a), Math.abs(b));
|
|
123
|
+
return Math.abs(a - b) <= 1e-9 * (scale >= 1 ? scale : 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where the written data would read back differently: strings, booleans and which fields are
|
|
128
|
+
* present, and with `numbers` the numbers too. Numbers are left out otherwise, because a value
|
|
129
|
+
* given with more precision than the format carries is written to the format's precision.
|
|
130
|
+
*/
|
|
131
|
+
function shapeDifferences(a: unknown, b: unknown, path: string, out: string[], numbers: boolean): void {
|
|
132
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
133
|
+
if (numbers && !sameNumber(a, b)) out.push(`${path.replace(/\.$/, '')} would read back as ${a}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
120
136
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
121
137
|
if (a.length !== b.length) out.push(`${path.replace(/\.$/, '')} would have ${a.length} items, not ${b.length}`);
|
|
122
|
-
else a.forEach((x, i) => shapeDifferences(x, b[i], `${path}${i}.`, out));
|
|
138
|
+
else a.forEach((x, i) => shapeDifferences(x, b[i], `${path}${i}.`, out, numbers));
|
|
123
139
|
return;
|
|
124
140
|
}
|
|
125
141
|
if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
|
|
@@ -128,7 +144,7 @@ function shapeDifferences(a: unknown, b: unknown, path: string, out: string[]):
|
|
|
128
144
|
for (const k of new Set([...Object.keys(ao), ...Object.keys(bo)])) {
|
|
129
145
|
if (!(k in ao)) out.push(`${path}${k} would be lost`);
|
|
130
146
|
else if (!(k in bo)) out.push(`${path}${k} would appear`);
|
|
131
|
-
else shapeDifferences(ao[k], bo[k], `${path}${k}.`, out);
|
|
147
|
+
else shapeDifferences(ao[k], bo[k], `${path}${k}.`, out, numbers);
|
|
132
148
|
}
|
|
133
149
|
return;
|
|
134
150
|
}
|
|
@@ -140,14 +156,19 @@ function expected(data: AprsData, a: Attempt): AprsData {
|
|
|
140
156
|
let d = data;
|
|
141
157
|
if ((d.type === 'position' || d.type === 'object' || d.type === 'item') && d.compressed && a.compression) {
|
|
142
158
|
if (!d.compression) d = { ...d, compression: a.compression };
|
|
143
|
-
if (d.courseDegrees !== undefined && d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
|
|
144
159
|
}
|
|
145
|
-
if (d.type === 'mic-e') {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 } } };
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
return d;
|
|
@@ -162,10 +183,25 @@ function check(data: AprsData, a: Attempt): string[] {
|
|
|
162
183
|
problems.push(`it would read back as ${decoded.data.type}`);
|
|
163
184
|
return problems;
|
|
164
185
|
}
|
|
165
|
-
|
|
186
|
+
// An NMEA sentence is written as it is, so everything read from it must be what the data says.
|
|
187
|
+
shapeDifferences(withoutInnerInfo(toNeutralData(decoded.data)), withoutInnerInfo(toNeutralData(expected(data, a))), '', problems, data.type === 'nmea');
|
|
166
188
|
return problems;
|
|
167
189
|
}
|
|
168
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
|
+
|
|
169
205
|
/**
|
|
170
206
|
* Encodes data as an information field. Mic-E also gives the destination address it computed.
|
|
171
207
|
* Throws `AprsEncodeError`, with the reason, for anything the spec does not allow.
|
|
@@ -175,8 +211,9 @@ export function encodeInformation(data: AprsData): EncodedInformation {
|
|
|
175
211
|
let problems = check(data, first);
|
|
176
212
|
let chosen = first;
|
|
177
213
|
if (problems.length > 0 && typeof (data as { comment?: unknown }).comment === 'string') {
|
|
178
|
-
// The comment would read as something else: try it straight after a frequency, then after a
|
|
179
|
-
|
|
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) {
|
|
180
217
|
let next: Attempt | undefined;
|
|
181
218
|
try {
|
|
182
219
|
next = attempt(data, mode);
|
package/src/encode/other.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// telemetry, queries, capabilities, and the rest.
|
|
3
3
|
|
|
4
4
|
import { encodeUtf8Binary } from '../bytes.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { DIRECTED_QUERY_TYPES } from '../decode/message.js';
|
|
6
|
+
import { splitNmea } from '../decode/other.js';
|
|
7
|
+
import { formatPath, isAprsIsAddress } from '../header.js';
|
|
7
8
|
import type {
|
|
8
9
|
Ack,
|
|
9
10
|
AgreloDf,
|
|
@@ -124,7 +125,10 @@ export function encodeDirectedQuery(d: DirectedQuery): string {
|
|
|
124
125
|
let out = `:${addressee(d.addressee)}:?${d.queryType}`;
|
|
125
126
|
if (d.target !== undefined) {
|
|
126
127
|
if (!/^[A-Za-z0-9-]{1,9}$/.test(d.target)) refuse('a query target is one callsign');
|
|
127
|
-
|
|
128
|
+
// A type the spec defines is followed straight by the target, an APRSH target padded to 9
|
|
129
|
+
// characters; any other type has no fixed length, so its target follows one space.
|
|
130
|
+
if (!DIRECTED_QUERY_TYPES.includes(d.queryType)) out += ` ${d.target}`;
|
|
131
|
+
else out += d.queryType === 'APRSH' ? d.target.padEnd(9, ' ') : d.target;
|
|
128
132
|
}
|
|
129
133
|
return out;
|
|
130
134
|
}
|
|
@@ -150,7 +154,7 @@ export function encodeStatus(d: StatusReport): string {
|
|
|
150
154
|
}
|
|
151
155
|
out += freeText(text, 'status text');
|
|
152
156
|
if (d.beam) {
|
|
153
|
-
if (!/^[0-9A-Z]$/.test(d.beam.headingCode) || !/^[
|
|
157
|
+
if (!/^[0-9A-Z]$/.test(d.beam.headingCode) || !/^[1-9:;<=>?@A-K]$/.test(d.beam.powerCode))
|
|
154
158
|
refuse('beam heading is 0-9 or A-Z, and power 1-9 or :-K');
|
|
155
159
|
out += `^${d.beam.headingCode}${d.beam.powerCode}`;
|
|
156
160
|
}
|
|
@@ -186,10 +190,12 @@ export function encodeRawWeather(d: RawWeather): string {
|
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
export function encodeNmea(d: NmeaSentence): string {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
+
const parts = splitNmea(d.sentence);
|
|
194
|
+
if (!parts || parts.rest.length > 0) refuse('not an NMEA 0183 sentence');
|
|
195
|
+
if (!parts.checksumOk) refuse("the NMEA sentence's checksum does not match");
|
|
196
|
+
const comment = d.comment ?? '';
|
|
197
|
+
if (comment.length > 0 && !parts.hasChecksum) refuse('a comment after an NMEA sentence needs the checksum that ends the sentence');
|
|
198
|
+
return `$${d.sentence}${freeText(comment, 'the comment')}`;
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
export function encodeMaidenhead(d: MaidenheadBeacon): string {
|
|
@@ -203,20 +209,36 @@ export function encodeQuery(d: Query): string {
|
|
|
203
209
|
if (d.footprint) {
|
|
204
210
|
const f = d.footprint;
|
|
205
211
|
if (!(f.latitude >= -90 && f.latitude <= 90) || !(f.longitude >= -180 && f.longitude <= 180)) refuse('the footprint is out of range');
|
|
206
|
-
|
|
207
|
-
|
|
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)}`;
|
|
208
221
|
}
|
|
209
222
|
return out;
|
|
210
223
|
}
|
|
211
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
|
+
|
|
228
|
+
/** A control character: below U+0020, or U+007F. */
|
|
229
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
230
|
+
|
|
212
231
|
export function encodeCapabilities(d: Capabilities): string {
|
|
213
232
|
if (d.capabilities.length === 0) refuse('capabilities need at least one item');
|
|
233
|
+
// Anything that would not read back the same (vectors interpretations.md, "Station
|
|
234
|
+
// capabilities: items, tokens and values") is refused.
|
|
214
235
|
const items = d.capabilities.map((c) => {
|
|
215
236
|
const token = c[0];
|
|
216
|
-
if (token.length === 0 || /[
|
|
237
|
+
if (token.length === 0 || /[ ,=]/.test(token) || CONTROL_RE.test(token)) refuse('a capability token is not empty and has no space, control character, comma or =');
|
|
217
238
|
if (c.length === 1) return token;
|
|
218
239
|
const value = c[1]!;
|
|
219
|
-
if (value.includes(',') ||
|
|
240
|
+
if (value.includes(',') || CONTROL_RE.test(value) || value.startsWith(' ') || value.endsWith(' '))
|
|
241
|
+
refuse('a capability value has no comma or control character, and does not start or end with a space');
|
|
220
242
|
return `${token}=${value}`;
|
|
221
243
|
});
|
|
222
244
|
return '<' + encodeUtf8Binary(items.join(','));
|
|
@@ -241,7 +263,11 @@ export function encodeAgrelo(d: AgreloDf): string {
|
|
|
241
263
|
/** The third-party header and the encapsulated packet's information field. */
|
|
242
264
|
export function encodeThirdParty(d: ThirdParty, innerInfo: string): string {
|
|
243
265
|
const p = d.packet;
|
|
266
|
+
// The source is 1-9 printable ASCII characters other than > and : (APRS12c ch. 17); the
|
|
267
|
+
// destination and path are APRS-IS addresses.
|
|
268
|
+
if (!/^[\x20-\x39\x3b-\x3d\x3f-\x7e]{1,9}$/.test(p.source)) refuse('a third-party source is 1-9 printable ASCII characters other than > and :');
|
|
269
|
+
if (!isAprsIsAddress(p.destination)) refuse('the third-party destination is not a valid address');
|
|
270
|
+
for (const e of p.path) if (!isAprsIsAddress(e.address)) refuse(`the third-party path entry ${JSON.stringify(e.address)} is not a valid address`);
|
|
244
271
|
const header = p.path.length > 0 ? `${p.source}>${p.destination},${formatPath(p.path)}` : `${p.source}>${p.destination}`;
|
|
245
|
-
if (!/^[\x21-\x7e]+$/.test(header) || header.includes(':')) refuse('the third-party header is not printable');
|
|
246
272
|
return `}${header}:${innerInfo}`;
|
|
247
273
|
}
|