@packet-net/pdn-aprs 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +661 -0
  3. package/README.md +192 -0
  4. package/THIRD-PARTY-NOTICES.md +5 -0
  5. package/dist/builder.d.ts +232 -0
  6. package/dist/builder.d.ts.map +1 -0
  7. package/dist/builder.js +508 -0
  8. package/dist/builder.js.map +1 -0
  9. package/dist/bytes.d.ts +19 -0
  10. package/dist/bytes.d.ts.map +1 -0
  11. package/dist/bytes.js +120 -0
  12. package/dist/bytes.js.map +1 -0
  13. package/dist/codes.d.ts +13 -0
  14. package/dist/codes.d.ts.map +1 -0
  15. package/dist/codes.js +112 -0
  16. package/dist/codes.js.map +1 -0
  17. package/dist/decode/context.d.ts +33 -0
  18. package/dist/decode/context.d.ts.map +1 -0
  19. package/dist/decode/context.js +65 -0
  20. package/dist/decode/context.js.map +1 -0
  21. package/dist/decode/index.d.ts +10 -0
  22. package/dist/decode/index.d.ts.map +1 -0
  23. package/dist/decode/index.js +121 -0
  24. package/dist/decode/index.js.map +1 -0
  25. package/dist/decode/message.d.ts +5 -0
  26. package/dist/decode/message.d.ts.map +1 -0
  27. package/dist/decode/message.js +190 -0
  28. package/dist/decode/message.js.map +1 -0
  29. package/dist/decode/other.d.ts +25 -0
  30. package/dist/decode/other.d.ts.map +1 -0
  31. package/dist/decode/other.js +326 -0
  32. package/dist/decode/other.js.map +1 -0
  33. package/dist/decode/positioned.d.ts +132 -0
  34. package/dist/decode/positioned.d.ts.map +1 -0
  35. package/dist/decode/positioned.js +1063 -0
  36. package/dist/decode/positioned.js.map +1 -0
  37. package/dist/decode/reports.d.ts +13 -0
  38. package/dist/decode/reports.d.ts.map +1 -0
  39. package/dist/decode/reports.js +375 -0
  40. package/dist/decode/reports.js.map +1 -0
  41. package/dist/decoder.d.ts +23 -0
  42. package/dist/decoder.d.ts.map +1 -0
  43. package/dist/decoder.js +75 -0
  44. package/dist/decoder.js.map +1 -0
  45. package/dist/deviceid-data.d.ts +33 -0
  46. package/dist/deviceid-data.d.ts.map +1 -0
  47. package/dist/deviceid-data.js +2941 -0
  48. package/dist/deviceid-data.js.map +1 -0
  49. package/dist/deviceid.d.ts +12 -0
  50. package/dist/deviceid.d.ts.map +1 -0
  51. package/dist/deviceid.js +86 -0
  52. package/dist/deviceid.js.map +1 -0
  53. package/dist/diagnostics.d.ts +48 -0
  54. package/dist/diagnostics.d.ts.map +1 -0
  55. package/dist/diagnostics.js +65 -0
  56. package/dist/diagnostics.js.map +1 -0
  57. package/dist/encode/common.d.ts +19 -0
  58. package/dist/encode/common.d.ts.map +1 -0
  59. package/dist/encode/common.js +84 -0
  60. package/dist/encode/common.js.map +1 -0
  61. package/dist/encode/index.d.ts +35 -0
  62. package/dist/encode/index.d.ts.map +1 -0
  63. package/dist/encode/index.js +248 -0
  64. package/dist/encode/index.js.map +1 -0
  65. package/dist/encode/other.d.ts +21 -0
  66. package/dist/encode/other.d.ts.map +1 -0
  67. package/dist/encode/other.js +253 -0
  68. package/dist/encode/other.js.map +1 -0
  69. package/dist/encode/position.d.ts +31 -0
  70. package/dist/encode/position.d.ts.map +1 -0
  71. package/dist/encode/position.js +729 -0
  72. package/dist/encode/position.js.map +1 -0
  73. package/dist/header.d.ts +42 -0
  74. package/dist/header.d.ts.map +1 -0
  75. package/dist/header.js +206 -0
  76. package/dist/header.js.map +1 -0
  77. package/dist/index.d.ts +13 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +14 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/neutral.d.ts +22 -0
  82. package/dist/neutral.d.ts.map +1 -0
  83. package/dist/neutral.js +593 -0
  84. package/dist/neutral.js.map +1 -0
  85. package/dist/symbols-data.d.ts +832 -0
  86. package/dist/symbols-data.d.ts.map +1 -0
  87. package/dist/symbols-data.js +173 -0
  88. package/dist/symbols-data.js.map +1 -0
  89. package/dist/symbols.d.ts +35 -0
  90. package/dist/symbols.d.ts.map +1 -0
  91. package/dist/symbols.js +71 -0
  92. package/dist/symbols.js.map +1 -0
  93. package/dist/types.d.ts +458 -0
  94. package/dist/types.d.ts.map +1 -0
  95. package/dist/types.js +4 -0
  96. package/dist/types.js.map +1 -0
  97. package/package.json +68 -0
  98. package/src/builder.ts +617 -0
  99. package/src/bytes.ts +118 -0
  100. package/src/codes.ts +218 -0
  101. package/src/decode/context.ts +63 -0
  102. package/src/decode/index.ts +135 -0
  103. package/src/decode/message.ts +194 -0
  104. package/src/decode/other.ts +307 -0
  105. package/src/decode/positioned.ts +1161 -0
  106. package/src/decode/reports.ts +378 -0
  107. package/src/decoder.ts +76 -0
  108. package/src/deviceid-data.ts +2954 -0
  109. package/src/deviceid.ts +83 -0
  110. package/src/diagnostics.ts +86 -0
  111. package/src/encode/common.ts +87 -0
  112. package/src/encode/index.ts +280 -0
  113. package/src/encode/other.ts +247 -0
  114. package/src/encode/position.ts +656 -0
  115. package/src/header.ts +221 -0
  116. package/src/index.ts +57 -0
  117. package/src/neutral.ts +595 -0
  118. package/src/symbols-data.ts +181 -0
  119. package/src/symbols.ts +85 -0
  120. package/src/types.ts +571 -0
@@ -0,0 +1,194 @@
1
+ // Messages, acks and rejects, bulletins, telemetry metadata and directed queries (APRS12c ch.
2
+ // 13-15).
3
+
4
+ import type { AprsData } from '../types.js';
5
+ import type { DecodeContext } from './context.js';
6
+
7
+ /** Known directed query types (APRS12c ch. 15), longest first. */
8
+ const DIRECTED_QUERY_TYPES = ['APRSD', 'APRSH', 'APRSM', 'APRSO', 'APRSP', 'APRSS', 'APRST', 'PING?'];
9
+
10
+ const MESSAGE_ID_RE = /^([A-Za-z0-9]{1,5})(?:\}([A-Za-z0-9]{0,5}))?$/;
11
+ const ACK_RE = /^(ack|rej)([A-Za-z0-9]{1,5})(?:\}([A-Za-z0-9]{0,5}))?(\{.*)?$/;
12
+ const COEFFICIENT_RE = /^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
13
+
14
+ interface SplitText {
15
+ text: string;
16
+ messageId?: string;
17
+ replyAck?: string;
18
+ }
19
+
20
+ /** Splits `text{id}ack` into its parts; reports a brace that does not start an ID. */
21
+ function splitMessageId(ctx: DecodeContext, text: string): SplitText {
22
+ const brace = text.lastIndexOf('{');
23
+ if (brace < 0) return { text };
24
+ const m = MESSAGE_ID_RE.exec(text.slice(brace + 1));
25
+ if (!m) {
26
+ ctx.tolerate('brace-in-message-text');
27
+ return { text };
28
+ }
29
+ const out: SplitText = { text: text.slice(0, brace), messageId: m[1]! };
30
+ if (m[2] !== undefined) out.replyAck = m[2];
31
+ if (out.text.includes('{')) ctx.tolerate('brace-in-message-text');
32
+ return out;
33
+ }
34
+
35
+ function parseList(body: string): string[] {
36
+ return body.split(',');
37
+ }
38
+
39
+ /** `:`: a message, or one of the things sent in message form. */
40
+ export function decodeMessage(ctx: DecodeContext, s: string): AprsData {
41
+ if (s[1] === ':') ctx.fail('invalid-message');
42
+ let colon = -1;
43
+ if (s.length > 10 && s[10] === ':') {
44
+ colon = 10;
45
+ } else {
46
+ for (let k = 2; k <= 9 && k < s.length; k++) {
47
+ if (s[k] === ':') {
48
+ colon = k;
49
+ break;
50
+ }
51
+ }
52
+ if (colon < 0) ctx.fail('invalid-message');
53
+ ctx.tolerate('unpadded-addressee');
54
+ }
55
+ const addressee = s.slice(1, colon).replace(/ +$/, '');
56
+ if (addressee.length === 0 || !/^[\x20-\x7e]+$/.test(addressee)) ctx.fail('invalid-message');
57
+ if (/[ :]/.test(addressee)) ctx.tolerate('invalid-addressee-characters');
58
+ const body = s.slice(colon + 1);
59
+
60
+ // Acks and rejects.
61
+ const ack = ACK_RE.exec(body);
62
+ if (ack) {
63
+ if (ack[4] !== undefined) ctx.tolerate('message-id-on-ack');
64
+ const replyAck = ack[3];
65
+ if (ack[1] === 'ack') {
66
+ return replyAck === undefined
67
+ ? { type: 'ack', addressee, ackedId: ack[2]! }
68
+ : { type: 'ack', addressee, ackedId: ack[2]!, replyAck };
69
+ }
70
+ return replyAck === undefined
71
+ ? { type: 'reject', addressee, rejectedId: ack[2]! }
72
+ : { type: 'reject', addressee, rejectedId: ack[2]!, replyAck };
73
+ }
74
+
75
+ // Bulletins and announcements.
76
+ if (/^BLN[0-9]/.test(addressee) || /^BLN[A-Z]$/.test(addressee) || /^BLN[A-Z]./.test(addressee)) {
77
+ if (/^BLN[A-Z]./.test(addressee)) ctx.tolerate('letter-group-bulletin');
78
+ const split = splitMessageId(ctx, body);
79
+ const text = ctx.text(split.text);
80
+ return withId({ type: 'bulletin' as const, addressee, text }, split.messageId);
81
+ }
82
+ if (/^NWS[-_]/.test(addressee)) {
83
+ const split = splitMessageId(ctx, body);
84
+ const text = ctx.text(split.text);
85
+ return withId({ type: 'nws-bulletin' as const, addressee, text }, split.messageId);
86
+ }
87
+
88
+ // Directed queries.
89
+ if (body.startsWith('?')) {
90
+ const query = directedQuery(ctx, addressee, body);
91
+ if (query) return query;
92
+ }
93
+
94
+ const split = splitMessageId(ctx, body);
95
+
96
+ // Telemetry metadata: its structure is checked before the text's encoding.
97
+ const meta = telemetryMetadata(ctx, addressee, split.text, split.messageId);
98
+ if (meta) return meta;
99
+ const text = ctx.text(split.text);
100
+
101
+ const message: { type: 'message'; addressee: string; text: string; messageId?: string; replyAck?: string } = {
102
+ type: 'message',
103
+ addressee,
104
+ text,
105
+ };
106
+ if (split.messageId !== undefined) message.messageId = split.messageId;
107
+ if (split.replyAck !== undefined) message.replyAck = split.replyAck;
108
+ return message;
109
+ }
110
+
111
+ function withId<T extends object>(data: T, messageId: string | undefined): T {
112
+ return messageId === undefined ? data : { ...data, messageId };
113
+ }
114
+
115
+ function directedQuery(ctx: DecodeContext, addressee: string, body: string): AprsData | undefined {
116
+ const q = body.slice(1);
117
+ if (q.includes('{')) {
118
+ ctx.info('invalid-query');
119
+ return undefined;
120
+ }
121
+ const known = DIRECTED_QUERY_TYPES.find((t) => q.startsWith(t));
122
+ const knownLower = DIRECTED_QUERY_TYPES.find((t) => q.toUpperCase().startsWith(t));
123
+ let queryType: string | undefined;
124
+ let rest = '';
125
+ if (known) {
126
+ queryType = known;
127
+ rest = q.slice(known.length);
128
+ } else if (knownLower) {
129
+ ctx.info('invalid-query');
130
+ return undefined;
131
+ } else {
132
+ const m = /^([A-Z]+)(?= |$)/.exec(q);
133
+ if (!m) return undefined;
134
+ queryType = m[1]!;
135
+ rest = q.slice(queryType.length);
136
+ }
137
+ if (rest.includes('{')) {
138
+ ctx.info('invalid-query');
139
+ return undefined;
140
+ }
141
+ const target = rest.trim();
142
+ if (target.length === 0) return { type: 'directed-query', addressee, queryType };
143
+ if (/^[A-Za-z0-9-]{1,9}$/.test(target)) return { type: 'directed-query', addressee, queryType, target };
144
+ ctx.info('invalid-query');
145
+ return undefined;
146
+ }
147
+
148
+ /** Telemetry metadata sent as a message; `binary` is the text's bytes, decoded once it is valid. */
149
+ function telemetryMetadata(
150
+ ctx: DecodeContext,
151
+ addressee: string,
152
+ binary: string,
153
+ messageId: string | undefined,
154
+ ): AprsData | undefined {
155
+ const kind = binary.slice(0, 5);
156
+ if (kind !== 'PARM.' && kind !== 'UNIT.' && kind !== 'EQNS.' && kind !== 'BITS.') return undefined;
157
+ const rawBody = binary.slice(5);
158
+ const invalid = (): undefined => {
159
+ ctx.info('invalid-telemetry-metadata');
160
+ return undefined;
161
+ };
162
+ if (kind === 'PARM.' || kind === 'UNIT.') {
163
+ if (parseList(rawBody).length > 13) return invalid();
164
+ const list = parseList(ctx.text(rawBody));
165
+ return kind === 'PARM.'
166
+ ? withId({ type: 'telemetry-names' as const, addressee, names: list }, messageId)
167
+ : withId({ type: 'telemetry-units' as const, addressee, units: list }, messageId);
168
+ }
169
+ if (kind === 'EQNS.') {
170
+ const list = parseList(rawBody);
171
+ // Trailing empty entries (commas and spaces) are the list stopping.
172
+ while (list.length > 0 && list[list.length - 1]!.trim() === '') list.pop();
173
+ if (list.length === 0 || list.length > 15) return invalid();
174
+ const texts: string[] = [];
175
+ for (const item of list) {
176
+ const t = item.trim();
177
+ if (!COEFFICIENT_RE.test(t)) return invalid();
178
+ texts.push(t);
179
+ }
180
+ return withId(
181
+ { type: 'telemetry-coefficients' as const, addressee, coefficients: texts.map(Number), coefficientsText: texts },
182
+ messageId,
183
+ );
184
+ }
185
+ const m = /^([01]{8})(?:,(.*))?$/s.exec(rawBody);
186
+ if (!m) return invalid();
187
+ const bits: { type: 'telemetry-bits'; addressee: string; bits: string; project?: string } = {
188
+ type: 'telemetry-bits',
189
+ addressee,
190
+ bits: m[1]!,
191
+ };
192
+ if (m[2] !== undefined && m[2].length > 0) bits.project = ctx.text(m[2]);
193
+ return withId(bits, messageId);
194
+ }
@@ -0,0 +1,307 @@
1
+ // Status reports, telemetry reports, queries, capabilities, NMEA, raw weather, user-defined and
2
+ // test data, Agrelo DF, Maidenhead beacons.
3
+
4
+ import type {
5
+ AgreloDf,
6
+ AprsTimestamp,
7
+ Capabilities,
8
+ Capability,
9
+ MaidenheadBeacon,
10
+ NmeaSentence,
11
+ Query,
12
+ RawWeather,
13
+ RawWeatherFormat,
14
+ StatusReport,
15
+ TelemetryReport,
16
+ TestData,
17
+ UserDefined,
18
+ } from '../types.js';
19
+ import type { DecodeContext } from './context.js';
20
+ import { isSymbolTable, timestampValid } from './positioned.js';
21
+
22
+ const LOCATOR6 = /^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}/;
23
+ const LOCATOR4 = /^[A-Ra-r]{2}[0-9]{2}/;
24
+
25
+ function printableCode(c: string | undefined): boolean {
26
+ if (c === undefined) return false;
27
+ const v = c.charCodeAt(0);
28
+ return v >= 0x21 && v <= 0x7e;
29
+ }
30
+
31
+ /** `>`: a status report. */
32
+ export function decodeStatus(ctx: DecodeContext, s: string): StatusReport {
33
+ let body = s.slice(1);
34
+ const out: { -readonly [K in keyof StatusReport]: StatusReport[K] } = { type: 'status' };
35
+ const ts = /^([0-9]{2})([0-9]{2})([0-9]{2})z/.exec(body);
36
+ if (ts) {
37
+ const timestamp: AprsTimestamp = { kind: 'dhm-zulu', day: Number(ts[1]), hour: Number(ts[2]), minute: Number(ts[3]) };
38
+ if (!timestampValid(timestamp)) ctx.tolerate('invalid-timestamp');
39
+ out.timestamp = timestamp;
40
+ body = body.slice(7);
41
+ } else {
42
+ let locatorLength = 0;
43
+ if (LOCATOR6.test(body)) {
44
+ if (isSymbolTable(body[6]) && printableCode(body[7])) locatorLength = 6;
45
+ } else if (LOCATOR4.test(body) && isSymbolTable(body[4]) && printableCode(body[5])) {
46
+ locatorLength = 4;
47
+ }
48
+ if (locatorLength > 0) {
49
+ out.locator = body.slice(0, locatorLength).toUpperCase();
50
+ out.symbol = { table: body[locatorLength]!, code: body[locatorLength + 1]! };
51
+ body = body.slice(locatorLength + 2);
52
+ if (body.length > 0) {
53
+ if (body[0] === ' ') body = body.slice(1);
54
+ else ctx.tolerate('missing-space-after-locator');
55
+ }
56
+ }
57
+ }
58
+ const beam = /\^([0-9A-Z])([0-9:;<=>?@A-K])$/.exec(body);
59
+ if (beam) {
60
+ out.beam = { headingCode: beam[1]!, powerCode: beam[2]! };
61
+ body = body.slice(0, beam.index);
62
+ }
63
+ if (body.length > 0) {
64
+ const text = ctx.text(body);
65
+ if (text.length > 0) out.text = text;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const TELEMETRY_VALUE_RE = /^-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)$/;
71
+
72
+ /** `T`: a telemetry report. */
73
+ export function decodeTelemetry(ctx: DecodeContext, s: string): TelemetryReport {
74
+ if (!s.startsWith('T#')) ctx.fail('invalid-telemetry');
75
+ let j: number;
76
+ let sequence: string;
77
+ if (s.startsWith('MIC', 2)) {
78
+ sequence = 'MIC';
79
+ j = 5;
80
+ if (s[j] === ',') j++;
81
+ } else {
82
+ const m = /^([A-Za-z0-9]+),/.exec(s.slice(2));
83
+ if (!m) ctx.fail('invalid-telemetry');
84
+ sequence = m[1]!;
85
+ j = 2 + m[0].length;
86
+ }
87
+ const analog: (number | null)[] = [];
88
+ const analogText: (string | null)[] = [];
89
+ let ended = false;
90
+ while (analog.length < 5) {
91
+ const comma = s.indexOf(',', j);
92
+ const end = comma < 0 ? s.length : comma;
93
+ const v = s.slice(j, end);
94
+ if (v.length === 0) {
95
+ analog.push(null);
96
+ analogText.push(null);
97
+ } else if (TELEMETRY_VALUE_RE.test(v)) {
98
+ analog.push(Number(v));
99
+ analogText.push(v);
100
+ } else {
101
+ ctx.fail('invalid-telemetry');
102
+ }
103
+ if (comma < 0) {
104
+ ended = true;
105
+ j = s.length;
106
+ break;
107
+ }
108
+ j = comma + 1;
109
+ }
110
+ const out: { -readonly [K in keyof TelemetryReport]: TelemetryReport[K] } = {
111
+ type: 'telemetry',
112
+ sequence,
113
+ analog,
114
+ analogText,
115
+ };
116
+ if (analog.length < 5 || ended) {
117
+ ctx.tolerate('invalid-telemetry');
118
+ return out;
119
+ }
120
+ const bits = s.slice(j, j + 8);
121
+ if (!/^[01]{8}$/.test(bits)) {
122
+ ctx.tolerate('invalid-telemetry');
123
+ const rest = s.slice(j);
124
+ if (rest.length > 0) out.comment = ctx.text(rest);
125
+ return out;
126
+ }
127
+ out.bits = bits;
128
+ const comment = s.slice(j + 8);
129
+ if (comment.length > 0) out.comment = ctx.text(comment);
130
+ return out;
131
+ }
132
+
133
+ /** `?`: a general query. */
134
+ export function decodeQuery(ctx: DecodeContext, s: string): Query {
135
+ const m = /^\?([A-Z]+)\?(.*)$/s.exec(s);
136
+ if (!m) ctx.fail('invalid-general-query');
137
+ const out: { -readonly [K in keyof Query]: Query[K] } = { type: 'query', queryType: m[1]! };
138
+ const rest = m[2]!;
139
+ if (rest.length > 0) {
140
+ const f = /^ ?(-?[0-9]+(?:\.[0-9]*)?),(-?[0-9]+(?:\.[0-9]*)?),([0-9]{4})$/.exec(rest);
141
+ if (!f) ctx.fail('invalid-general-query');
142
+ out.footprint = { latitude: Number(f[1]), longitude: Number(f[2]), radiusMiles: Number(f[3]) };
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** `<`: station capabilities. */
148
+ export function decodeCapabilities(ctx: DecodeContext, s: string): Capabilities {
149
+ const text = ctx.text(s.slice(1));
150
+ const capabilities: Capability[] = [];
151
+ let freeText = false;
152
+ for (const raw of text.split(',')) {
153
+ const item = raw.trim();
154
+ if (item.length === 0) continue; // empty items are skipped
155
+ const eq = item.indexOf('=');
156
+ const token = eq < 0 ? item : item.slice(0, eq);
157
+ if (/\s/.test(token) || token.length === 0) freeText = true;
158
+ capabilities.push(eq < 0 ? [item] : [token, item.slice(eq + 1)]);
159
+ }
160
+ if (capabilities.length === 0) ctx.fail('invalid-capabilities');
161
+ if (freeText) ctx.tolerate('free-text-capabilities');
162
+ return { type: 'capabilities', capabilities };
163
+ }
164
+
165
+ function nmeaDegrees(value: string | undefined, hemisphere: string | undefined, width: number): number | undefined {
166
+ if (value === undefined || hemisphere === undefined) return undefined;
167
+ if (!/^[0-9]+(?:\.[0-9]*)?$/.test(value)) return undefined;
168
+ const dot = value.indexOf('.');
169
+ const intPart = dot < 0 ? value : value.slice(0, dot);
170
+ if (intPart.length < 3) return undefined;
171
+ const degrees = Number(intPart.slice(0, intPart.length - 2));
172
+ const minutes = Number(value.slice(intPart.length - 2));
173
+ if (minutes >= 60) return undefined;
174
+ const v = degrees + minutes / 60;
175
+ if (v > width) return undefined;
176
+ if (hemisphere === 'S' || hemisphere === 'W') return v === 0 ? 0 : -v;
177
+ if (hemisphere === 'N' || hemisphere === 'E') return v;
178
+ return undefined;
179
+ }
180
+
181
+ function nmeaTime(value: string | undefined): string | undefined {
182
+ if (value === undefined) return undefined;
183
+ const m = /^([0-9]{2})([0-9]{2})([0-9]{2})(?:\.([0-9]*))?$/.exec(value);
184
+ if (!m) return undefined;
185
+ let t = `${m[1]}:${m[2]}:${m[3]}`;
186
+ const fraction = (m[4] ?? '').replace(/0+$/, '');
187
+ if (fraction.length > 0) t += `.${fraction}`;
188
+ return t;
189
+ }
190
+
191
+ function nmeaNumber(value: string | undefined): number | undefined {
192
+ if (value === undefined || !/^-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)$/.test(value)) return undefined;
193
+ return Number(value);
194
+ }
195
+
196
+ /** Verifies an NMEA sentence's checksum; `undefined` when it has none. */
197
+ export function nmeaChecksumOk(sentence: string): boolean | undefined {
198
+ const m = /\*([0-9A-Fa-f]{2})$/.exec(sentence);
199
+ if (!m) return undefined;
200
+ let x = 0;
201
+ for (let k = 0; k < m.index; k++) x ^= sentence.charCodeAt(k);
202
+ return x === parseInt(m[1]!, 16);
203
+ }
204
+
205
+ /** `$`: raw NMEA, or Ultimeter raw weather (`$ULTW`). */
206
+ export function decodeDollar(ctx: DecodeContext, s: string): NmeaSentence | RawWeather {
207
+ if (s.startsWith('$ULTW')) return decodeRawWeather(ctx, s.slice(5), 'ultimeter-packet');
208
+ ctx.info('obsolete-format');
209
+ const sentence = s.slice(1);
210
+ if (!/^[A-Z]{5},[\x20-\x7e]*$/.test(sentence)) ctx.fail('invalid-nmea');
211
+ const checksum = nmeaChecksumOk(sentence);
212
+ if (checksum === false) ctx.fail('nmea-checksum-mismatch');
213
+ const body = checksum === undefined ? sentence : sentence.slice(0, sentence.length - 3);
214
+ const f = body.split(',');
215
+ const out: { -readonly [K in keyof NmeaSentence]: NmeaSentence[K] } = { type: 'nmea', sentence };
216
+ if (checksum !== undefined) out.hasChecksum = true;
217
+ const set = <K extends keyof NmeaSentence>(key: K, value: NmeaSentence[K] | undefined): void => {
218
+ if (value !== undefined) out[key] = value;
219
+ };
220
+ switch (f[0]!.slice(2)) {
221
+ case 'GGA': {
222
+ set('latitude', nmeaDegrees(f[2], f[3], 90));
223
+ set('longitude', nmeaDegrees(f[4], f[5], 180));
224
+ if (f[6] !== undefined && /^[0-9]$/.test(f[6])) out.fix = f[6] === '0' ? 'invalid' : 'valid';
225
+ set('altitudeM', nmeaNumber(f[9]));
226
+ set('time', nmeaTime(f[1]));
227
+ break;
228
+ }
229
+ case 'GLL': {
230
+ set('latitude', nmeaDegrees(f[1], f[2], 90));
231
+ set('longitude', nmeaDegrees(f[3], f[4], 180));
232
+ set('time', nmeaTime(f[5]));
233
+ if (f[6] === 'A') out.fix = 'valid';
234
+ else if (f[6] === 'V') out.fix = 'invalid';
235
+ break;
236
+ }
237
+ case 'RMC': {
238
+ set('time', nmeaTime(f[1]));
239
+ if (f[2] === 'A') out.fix = 'valid';
240
+ else if (f[2] === 'V') out.fix = 'invalid';
241
+ set('latitude', nmeaDegrees(f[3], f[4], 90));
242
+ set('longitude', nmeaDegrees(f[5], f[6], 180));
243
+ set('speedKnots', nmeaNumber(f[7]));
244
+ set('courseDegrees', nmeaNumber(f[8]));
245
+ break;
246
+ }
247
+ case 'VTG': {
248
+ set('courseDegrees', nmeaNumber(f[1]));
249
+ set('speedKnots', nmeaNumber(f[5]));
250
+ break;
251
+ }
252
+ case 'WPL': {
253
+ set('latitude', nmeaDegrees(f[1], f[2], 90));
254
+ set('longitude', nmeaDegrees(f[3], f[4], 180));
255
+ if (f[5] !== undefined && f[5].length > 0) out.waypoint = f[5];
256
+ break;
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /** Raw weather station data, kept as text. */
263
+ export function decodeRawWeather(ctx: DecodeContext, data: string, format: RawWeatherFormat): RawWeather {
264
+ ctx.info('obsolete-format');
265
+ if (!/^[\x20-\x7e]*$/.test(data)) ctx.fail('invalid-weather');
266
+ return data.length > 0 ? { type: 'raw-weather', format, data } : { type: 'raw-weather', format };
267
+ }
268
+
269
+ /** `{`: user-defined data, kept as bytes. */
270
+ export function decodeUserDefined(ctx: DecodeContext, s: string): UserDefined {
271
+ if (s.length < 3) ctx.fail('invalid-user-defined');
272
+ const data = s.slice(3);
273
+ const out: { -readonly [K in keyof UserDefined]: UserDefined[K] } = {
274
+ type: 'user-defined',
275
+ userId: s[1]!,
276
+ packetType: s[2]!,
277
+ };
278
+ if (data.length > 0) out.data = data;
279
+ return out;
280
+ }
281
+
282
+ /** `,`: invalid or test data. */
283
+ export function decodeTest(ctx: DecodeContext, s: string): TestData {
284
+ const data = ctx.text(s.slice(1));
285
+ return data.length > 0 ? { type: 'test', data } : { type: 'test' };
286
+ }
287
+
288
+ /** `%`: an Agrelo DFJr / MicroFinder report. */
289
+ export function decodeAgrelo(ctx: DecodeContext, s: string): AgreloDf {
290
+ const m = /^%([0-9]{3})\/([0-9])/.exec(s);
291
+ if (!m) ctx.fail('invalid-agrelo-df');
292
+ return { type: 'agrelo-df', bearingDegrees: Number(m[1]), quality: Number(m[2]) };
293
+ }
294
+
295
+ /** `[`: a Maidenhead locator beacon. */
296
+ export function decodeMaidenhead(ctx: DecodeContext, s: string): MaidenheadBeacon {
297
+ ctx.info('obsolete-format');
298
+ const m = /^\[([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\]/.exec(s);
299
+ if (!m) ctx.fail('invalid-locator');
300
+ const out: { -readonly [K in keyof MaidenheadBeacon]: MaidenheadBeacon[K] } = {
301
+ type: 'maidenhead-beacon',
302
+ locator: m[1]!.toUpperCase(),
303
+ };
304
+ const rest = s.slice(m[0].length);
305
+ if (rest.length > 0) out.comment = ctx.text(rest);
306
+ return out;
307
+ }