@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +9 -5
  3. package/dist/builder.d.ts +33 -8
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +64 -10
  6. package/dist/builder.js.map +1 -1
  7. package/dist/bytes.d.ts.map +1 -1
  8. package/dist/bytes.js +2 -1
  9. package/dist/bytes.js.map +1 -1
  10. package/dist/codes.js +8 -8
  11. package/dist/codes.js.map +1 -1
  12. package/dist/decode/index.d.ts.map +1 -1
  13. package/dist/decode/index.js +8 -5
  14. package/dist/decode/index.js.map +1 -1
  15. package/dist/decode/message.d.ts +2 -0
  16. package/dist/decode/message.d.ts.map +1 -1
  17. package/dist/decode/message.js +59 -33
  18. package/dist/decode/message.js.map +1 -1
  19. package/dist/decode/other.d.ts +20 -2
  20. package/dist/decode/other.d.ts.map +1 -1
  21. package/dist/decode/other.js +110 -48
  22. package/dist/decode/other.js.map +1 -1
  23. package/dist/decode/positioned.d.ts +26 -2
  24. package/dist/decode/positioned.d.ts.map +1 -1
  25. package/dist/decode/positioned.js +145 -79
  26. package/dist/decode/positioned.js.map +1 -1
  27. package/dist/decode/reports.d.ts.map +1 -1
  28. package/dist/decode/reports.js +47 -5
  29. package/dist/decode/reports.js.map +1 -1
  30. package/dist/decoder.d.ts.map +1 -1
  31. package/dist/decoder.js +2 -4
  32. package/dist/decoder.js.map +1 -1
  33. package/dist/deviceid.d.ts +8 -2
  34. package/dist/deviceid.d.ts.map +1 -1
  35. package/dist/deviceid.js +28 -6
  36. package/dist/deviceid.js.map +1 -1
  37. package/dist/encode/common.d.ts +13 -2
  38. package/dist/encode/common.d.ts.map +1 -1
  39. package/dist/encode/common.js +36 -4
  40. package/dist/encode/common.js.map +1 -1
  41. package/dist/encode/index.d.ts.map +1 -1
  42. package/dist/encode/index.js +59 -22
  43. package/dist/encode/index.js.map +1 -1
  44. package/dist/encode/other.d.ts.map +1 -1
  45. package/dist/encode/other.js +45 -17
  46. package/dist/encode/other.js.map +1 -1
  47. package/dist/encode/position.d.ts +9 -3
  48. package/dist/encode/position.d.ts.map +1 -1
  49. package/dist/encode/position.js +258 -124
  50. package/dist/encode/position.js.map +1 -1
  51. package/dist/header.d.ts +5 -2
  52. package/dist/header.d.ts.map +1 -1
  53. package/dist/header.js +8 -3
  54. package/dist/header.js.map +1 -1
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/neutral.d.ts.map +1 -1
  59. package/dist/neutral.js +22 -3
  60. package/dist/neutral.js.map +1 -1
  61. package/dist/types.d.ts +19 -9
  62. package/dist/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/builder.ts +77 -14
  65. package/src/bytes.ts +2 -1
  66. package/src/codes.ts +8 -8
  67. package/src/decode/index.ts +7 -4
  68. package/src/decode/message.ts +54 -37
  69. package/src/decode/other.ts +116 -44
  70. package/src/decode/positioned.ts +159 -90
  71. package/src/decode/reports.ts +47 -4
  72. package/src/decoder.ts +2 -5
  73. package/src/deviceid.ts +29 -5
  74. package/src/encode/common.ts +34 -4
  75. package/src/encode/index.ts +57 -20
  76. package/src/encode/other.ts +39 -13
  77. package/src/encode/position.ts +261 -119
  78. package/src/header.ts +9 -2
  79. package/src/index.ts +1 -0
  80. package/src/neutral.ts +24 -3
  81. 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
- /** The device a destination address (tocall) identifies, e.g. `APDW18` is Dire Wolf. */
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]!.toUpperCase();
53
- for (const t of compile()) if (t.regex.test(call)) return toInfo(t.entry);
54
- return undefined;
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
+ }
@@ -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
- /** A whole number within [min, max], else refused. */
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 = Math.round(value);
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
- /** Formats a number as short text: `0.53`, `-32`, `1e-7`. */
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
- return String(v);
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
  }
@@ -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), a `/` delimiter is tried, and if that
4
- // does not help either the encoder refuses.
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 = original.length > 0 ? original : encodeInformation(data.packet.data).info;
104
- return { binary: encodeThirdParty(data, bytesToBinary(inner)) };
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
- /** Where the written data would read back differently: strings, booleans and which fields are present. */
118
- function shapeDifferences(a: unknown, b: unknown, path: string, out: string[]): void {
119
- if (typeof a === 'number' && typeof b === 'number') return;
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
- if (d.speedKnots === undefined) d = { ...d, speedKnots: 0 };
147
- if (d.courseDegrees === 0) {
148
- const copy: Record<string, unknown> = { ...d };
149
- delete copy.courseDegrees;
150
- d = copy as unknown as AprsData;
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
- shapeDifferences(toNeutralData(decoded.data), toNeutralData(expected(data, a)), '', problems);
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
- for (const mode of ['joined', 'delimited'] as const) {
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);
@@ -2,8 +2,9 @@
2
2
  // telemetry, queries, capabilities, and the rest.
3
3
 
4
4
  import { encodeUtf8Binary } from '../bytes.js';
5
- import { nmeaChecksumOk } from '../decode/other.js';
6
- import { formatPath } from '../header.js';
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
- out += d.target;
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) || !/^[0-9:;<=>?@A-K]$/.test(d.beam.powerCode))
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
- if (!/^[A-Z]{5},[\x20-\x7e]*$/.test(d.sentence)) refuse('not an NMEA sentence');
190
- const checksum = nmeaChecksumOk(d.sentence);
191
- if (checksum === false) refuse("the NMEA sentence's checksum does not match");
192
- return `$${d.sentence}`;
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
- const coord = (v: number): string => (v < 0 ? numberText(v) : ` ${numberText(v)}`);
207
- out += `${coord(f.latitude)},${numberText(f.longitude)},${pad(whole(f.radiusMiles, 0, 9999, 'radius'), 4)}`;
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 || /[\s,=]/.test(token)) refuse('a capability token has no space, comma or =');
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(',') || /^\s|\s$/.test(value)) refuse('a capability value has no comma or edge spaces');
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
  }