@springfield/ham-radio-utils 4.1.2 → 4.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.
@@ -0,0 +1,658 @@
1
+ import type {
2
+ RadioMemoryConfig,
3
+ RadioMemoryMap,
4
+ RadioMemoryMapField,
5
+ RadioMemoryMapStruct,
6
+ RadioMemoryMapValueKind,
7
+ RadioSettings,
8
+ RadioSettingValue,
9
+ } from '@springfield/ham-radio-api';
10
+
11
+ const DEFAULT_DTMF_CHARSET = '0123456789 *#ABCD';
12
+
13
+ /**
14
+ * Parse a seek address from a number or hex/decimal string.
15
+ */
16
+ export function parseSeekAddress(seek: number | string): number {
17
+ if (typeof seek === 'number') {
18
+ return seek;
19
+ }
20
+
21
+ const trimmed = seek.trim();
22
+
23
+ if (/^0x/i.test(trimmed)) {
24
+ return Number.parseInt(trimmed, 16);
25
+ }
26
+
27
+ return Number.parseInt(trimmed, 10);
28
+ }
29
+
30
+ /**
31
+ * Map a radio EEPROM address to an offset in the memory buffer.
32
+ *
33
+ * Packed buffers (driver read) concatenate segments in config order.
34
+ * Sparse buffers that cover the highest segment end address use absolute offsets.
35
+ */
36
+ export function radioAddressToBufferOffset(
37
+ radioAddress: number,
38
+ memoryConfig: RadioMemoryConfig,
39
+ bufferLength: number,
40
+ ): number {
41
+ const segments = Object.values(memoryConfig.segments);
42
+ const maxEndAddress = Math.max(...segments.map((segment) => segment.endAddress));
43
+
44
+ if (bufferLength >= maxEndAddress + 1) {
45
+ return radioAddress;
46
+ }
47
+
48
+ let offset = 0;
49
+
50
+ for (const segment of segments) {
51
+ if (radioAddress >= segment.startAddress && radioAddress <= segment.endAddress) {
52
+ return offset + (radioAddress - segment.startAddress);
53
+ }
54
+
55
+ offset += segment.endAddress - segment.startAddress + 1;
56
+ }
57
+
58
+ throw new Error(`Radio address 0x${radioAddress.toString(16)} is not in any memory segment`);
59
+ }
60
+
61
+ function bufferOffsetFor(radioAddress: number, memoryConfig: RadioMemoryConfig, contents: Uint8Array): number {
62
+ return radioAddressToBufferOffset(radioAddress, memoryConfig, contents.length);
63
+ }
64
+
65
+ function readByte(contents: Uint8Array, offset: number): number {
66
+ if (offset < 0 || offset >= contents.length) {
67
+ throw new RangeError(`Memory-map read out of bounds at offset ${offset}`);
68
+ }
69
+
70
+ return contents[offset];
71
+ }
72
+
73
+ function writeByte(contents: Uint8Array, offset: number, value: number): void {
74
+ if (offset < 0 || offset >= contents.length) {
75
+ throw new RangeError(`Memory-map write out of bounds at offset ${offset}`);
76
+ }
77
+
78
+ contents[offset] = value & 0xff;
79
+ }
80
+
81
+ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number | number[]): RadioSettingValue {
82
+ if (!kind) {
83
+ return typeof raw === 'number' ? raw : raw;
84
+ }
85
+
86
+ switch (kind.kind) {
87
+ case 'integer':
88
+ return typeof raw === 'number' ? raw : raw[0];
89
+ case 'boolean':
90
+ return (typeof raw === 'number' ? raw : raw[0]) !== 0;
91
+ case 'enum': {
92
+ const index = typeof raw === 'number' ? raw : raw[0];
93
+ return kind.values[index] ?? kind.values[0] ?? '';
94
+ }
95
+ case 'ascii': {
96
+ const bytes = typeof raw === 'number' ? [raw] : raw;
97
+ let text = '';
98
+
99
+ for (const byte of bytes) {
100
+ if (byte === 0xff || byte === 0x00) {
101
+ break;
102
+ }
103
+
104
+ text += String.fromCodePoint(byte);
105
+ }
106
+
107
+ return text.trimEnd();
108
+ }
109
+ case 'digits': {
110
+ const bytes = typeof raw === 'number' ? [raw] : raw;
111
+ let value = 0;
112
+
113
+ for (const digit of bytes) {
114
+ value = value * 10 + (digit & 0x0f);
115
+ }
116
+
117
+ return value * (kind.scale ?? 1);
118
+ }
119
+ case 'dtmf': {
120
+ const bytes = typeof raw === 'number' ? [raw] : raw;
121
+ const charset = kind.charset ?? DEFAULT_DTMF_CHARSET;
122
+ let text = '';
123
+
124
+ for (const byte of bytes) {
125
+ if (byte >= 0x1f) {
126
+ break;
127
+ }
128
+
129
+ if (byte < charset.length) {
130
+ text += charset[byte];
131
+ }
132
+ }
133
+
134
+ return text;
135
+ }
136
+ case 'bbcd': {
137
+ const bytes = typeof raw === 'number' ? [raw] : raw;
138
+ let value = 0;
139
+
140
+ for (const byte of bytes) {
141
+ value = value * 100 + ((byte >> 4) & 0x0f) * 10 + (byte & 0x0f);
142
+ }
143
+
144
+ return value;
145
+ }
146
+ case 'lbcd': {
147
+ const bytes = typeof raw === 'number' ? [raw] : raw;
148
+ let word = 0;
149
+
150
+ for (let index = 0; index < bytes.length; index += 1) {
151
+ word |= bytes[index] << (8 * index);
152
+ }
153
+
154
+ if (word === 0xff_ff_ff_ff || bytes.every((byte) => byte === 0xff)) {
155
+ return null;
156
+ }
157
+
158
+ return Number.parseInt(word.toString(16), 10) * (kind.scale ?? 10);
159
+ }
160
+ case 'tone': {
161
+ const rawValue = typeof raw === 'number' ? raw : raw[0] | (raw[1] << 8);
162
+ const ctcssMin = kind.ctcssMin ?? 0x0258;
163
+ const reverseOffset = kind.reverseOffset ?? 0x69;
164
+
165
+ if (rawValue === 0 || rawValue === 0xffff) {
166
+ return { mode: 'none' };
167
+ }
168
+
169
+ if (rawValue >= ctcssMin) {
170
+ return { mode: 'ctcss', value: rawValue };
171
+ }
172
+
173
+ let index = rawValue;
174
+ let polarity: 'N' | 'R' = 'N';
175
+
176
+ if (rawValue > reverseOffset) {
177
+ index = rawValue - reverseOffset;
178
+ polarity = 'R';
179
+ } else {
180
+ index = rawValue - 1;
181
+ }
182
+
183
+ const code = kind.values[index] ?? 0;
184
+ return { mode: 'dcs', code, polarity };
185
+ }
186
+ default: {
187
+ const exhaustive: never = kind;
188
+ return exhaustive;
189
+ }
190
+ }
191
+ }
192
+
193
+ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioSettingValue, byteLength: number): number[] {
194
+ const bytes = Array.from({ length: byteLength }, () => 0xff);
195
+
196
+ if (!kind) {
197
+ if (typeof value === 'number') {
198
+ bytes[0] = value & 0xff;
199
+ }
200
+
201
+ return bytes;
202
+ }
203
+
204
+ switch (kind.kind) {
205
+ case 'integer': {
206
+ bytes[0] = typeof value === 'number' ? value & 0xff : 0;
207
+ return bytes;
208
+ }
209
+ case 'boolean': {
210
+ bytes[0] = value ? 1 : 0;
211
+ return bytes;
212
+ }
213
+ case 'enum': {
214
+ const index = typeof value === 'string' ? kind.values.indexOf(value) : -1;
215
+ bytes[0] = index >= 0 ? index : 0;
216
+ return bytes;
217
+ }
218
+ case 'ascii': {
219
+ const text = typeof value === 'string' ? value : '';
220
+
221
+ for (let index = 0; index < byteLength; index += 1) {
222
+ bytes[index] = index < text.length ? (text.codePointAt(index) ?? 0xff) : 0xff;
223
+ }
224
+
225
+ return bytes;
226
+ }
227
+ case 'digits': {
228
+ const scale = kind.scale ?? 1;
229
+ let numeric = typeof value === 'number' ? Math.round(value / scale) : 0;
230
+
231
+ for (let index = byteLength - 1; index >= 0; index -= 1) {
232
+ bytes[index] = numeric % 10;
233
+ numeric = Math.floor(numeric / 10);
234
+ }
235
+
236
+ return bytes;
237
+ }
238
+ case 'dtmf': {
239
+ const text = typeof value === 'string' ? value : '';
240
+ const charset = kind.charset ?? DEFAULT_DTMF_CHARSET;
241
+
242
+ for (let index = 0; index < byteLength; index += 1) {
243
+ if (index < text.length) {
244
+ const charIndex = charset.indexOf(text[index]);
245
+ bytes[index] = charIndex >= 0 ? charIndex : 0xff;
246
+ } else {
247
+ bytes[index] = 0xff;
248
+ }
249
+ }
250
+
251
+ return bytes;
252
+ }
253
+ case 'bbcd': {
254
+ let numeric = typeof value === 'number' ? value : 0;
255
+ const digits: number[] = [];
256
+
257
+ for (let index = 0; index < byteLength * 2; index += 1) {
258
+ digits.unshift(numeric % 10);
259
+ numeric = Math.floor(numeric / 10);
260
+ }
261
+
262
+ for (let index = 0; index < byteLength; index += 1) {
263
+ bytes[index] = ((digits[index * 2] & 0x0f) << 4) | (digits[index * 2 + 1] & 0x0f);
264
+ }
265
+
266
+ return bytes;
267
+ }
268
+ case 'lbcd': {
269
+ if (value === null || value === undefined) {
270
+ return Array.from({ length: byteLength }, () => 0xff);
271
+ }
272
+
273
+ const scale = kind.scale ?? 10;
274
+ const hz = typeof value === 'number' ? value : 0;
275
+ let word = Number.parseInt(Math.round(hz / scale).toString(10), 16);
276
+
277
+ for (let index = 0; index < byteLength; index += 1) {
278
+ bytes[index] = word & 0xff;
279
+ word >>= 8;
280
+ }
281
+
282
+ return bytes;
283
+ }
284
+ case 'tone': {
285
+ const ctcssMin = kind.ctcssMin ?? 0x0258;
286
+ const reverseOffset = kind.reverseOffset ?? 0x69;
287
+ let rawValue = 0;
288
+
289
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
290
+ const tone = value as { mode?: string; value?: number; code?: number; polarity?: string };
291
+
292
+ if (tone.mode === 'ctcss' && typeof tone.value === 'number') {
293
+ rawValue = tone.value;
294
+ } else if (tone.mode === 'dcs' && typeof tone.code === 'number') {
295
+ const index = kind.values.indexOf(tone.code);
296
+ rawValue = index >= 0 ? index + 1 : 0;
297
+
298
+ if (tone.polarity === 'R') {
299
+ rawValue += reverseOffset;
300
+ }
301
+ }
302
+ }
303
+
304
+ if (rawValue >= ctcssMin || rawValue === 0) {
305
+ // CTCSS or none: write LE u16
306
+ }
307
+
308
+ bytes[0] = rawValue & 0xff;
309
+ bytes[1] = (rawValue >> 8) & 0xff;
310
+ return bytes;
311
+ }
312
+ default: {
313
+ const exhaustive: never = kind;
314
+ return exhaustive;
315
+ }
316
+ }
317
+ }
318
+
319
+ function fieldByteLength(field: RadioMemoryMapField): number {
320
+ if (field.type === 'u16') {
321
+ return 2;
322
+ }
323
+
324
+ if (field.type === 'bits') {
325
+ return 0;
326
+ }
327
+
328
+ if (
329
+ field.value?.kind === 'ascii' ||
330
+ field.value?.kind === 'digits' ||
331
+ field.value?.kind === 'dtmf' ||
332
+ field.value?.kind === 'bbcd' ||
333
+ field.value?.kind === 'lbcd'
334
+ ) {
335
+ return field.value.length;
336
+ }
337
+
338
+ if (field.value?.kind === 'tone') {
339
+ return 2;
340
+ }
341
+
342
+ return 1;
343
+ }
344
+
345
+ /**
346
+ * Chirp-style bitfields: declaration order is MSB → LSB within each byte.
347
+ */
348
+ class BitfieldCursor {
349
+ private radioAddress: number;
350
+ private bitIndex = -1;
351
+ private currentByte = 0;
352
+ private dirty = false;
353
+
354
+ constructor(
355
+ private readonly contents: Uint8Array,
356
+ private readonly memoryConfig: RadioMemoryConfig,
357
+ startRadioAddress: number,
358
+ ) {
359
+ this.radioAddress = startRadioAddress;
360
+ }
361
+
362
+ private load(): void {
363
+ if (this.bitIndex >= 0) {
364
+ return;
365
+ }
366
+
367
+ this.currentByte = readByte(this.contents, bufferOffsetFor(this.radioAddress, this.memoryConfig, this.contents));
368
+ this.bitIndex = 7;
369
+ this.dirty = false;
370
+ }
371
+
372
+ private flushByte(): void {
373
+ if (this.dirty) {
374
+ writeByte(this.contents, bufferOffsetFor(this.radioAddress, this.memoryConfig, this.contents), this.currentByte);
375
+ this.dirty = false;
376
+ }
377
+ }
378
+
379
+ read(width: number): number {
380
+ let result = 0;
381
+
382
+ for (let bit = 0; bit < width; bit += 1) {
383
+ this.load();
384
+ result = (result << 1) | ((this.currentByte >> this.bitIndex) & 1);
385
+ this.bitIndex -= 1;
386
+
387
+ if (this.bitIndex < 0) {
388
+ this.radioAddress += 1;
389
+ }
390
+ }
391
+
392
+ return result;
393
+ }
394
+
395
+ write(width: number, value: number): void {
396
+ for (let bit = width - 1; bit >= 0; bit -= 1) {
397
+ this.load();
398
+ const mask = 1 << this.bitIndex;
399
+
400
+ if ((value >> bit) & 1) {
401
+ this.currentByte |= mask;
402
+ } else {
403
+ this.currentByte &= ~mask;
404
+ }
405
+
406
+ this.dirty = true;
407
+ this.bitIndex -= 1;
408
+
409
+ if (this.bitIndex < 0) {
410
+ this.flushByte();
411
+ this.radioAddress += 1;
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Absolute radio address of the next unused byte after this bitfield group.
418
+ */
419
+ nextRadioAddress(): number {
420
+ this.flushByte();
421
+
422
+ if (this.bitIndex >= 0) {
423
+ return this.radioAddress + 1;
424
+ }
425
+
426
+ return this.radioAddress;
427
+ }
428
+ }
429
+
430
+ function decodeStruct(
431
+ struct: RadioMemoryMapStruct,
432
+ contents: Uint8Array,
433
+ memoryConfig: RadioMemoryConfig,
434
+ instanceIndex: number,
435
+ ): Record<string, RadioSettingValue> {
436
+ const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
437
+ const result: Record<string, RadioSettingValue> = {};
438
+ let radioAddress = base;
439
+ let bitCursor: BitfieldCursor | undefined;
440
+
441
+ for (const field of struct.fields) {
442
+ if (field.type === 'bits') {
443
+ if (!bitCursor) {
444
+ bitCursor = new BitfieldCursor(contents, memoryConfig, radioAddress);
445
+ }
446
+
447
+ const raw = bitCursor.read(field.width ?? 1);
448
+
449
+ if (!field.reserved) {
450
+ result[field.id] = decodeRawValue(field.value ?? { kind: 'integer' }, raw);
451
+ }
452
+
453
+ continue;
454
+ }
455
+
456
+ if (bitCursor) {
457
+ radioAddress = bitCursor.nextRadioAddress();
458
+ bitCursor = undefined;
459
+ }
460
+
461
+ const length = fieldByteLength(field);
462
+ const bytes: number[] = [];
463
+
464
+ for (let index = 0; index < length; index += 1) {
465
+ bytes.push(readByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents)));
466
+ }
467
+
468
+ if (field.type === 'u16') {
469
+ if (!field.reserved) {
470
+ result[field.id] = decodeRawValue(field.value ?? { kind: 'integer' }, bytes);
471
+ }
472
+ } else if (!field.reserved) {
473
+ result[field.id] = decodeRawValue(field.value, length === 1 ? bytes[0] : bytes);
474
+ }
475
+
476
+ radioAddress += length;
477
+ }
478
+
479
+ return result;
480
+ }
481
+
482
+ function encodeStruct(
483
+ struct: RadioMemoryMapStruct,
484
+ values: Record<string, RadioSettingValue>,
485
+ contents: Uint8Array,
486
+ memoryConfig: RadioMemoryConfig,
487
+ instanceIndex: number,
488
+ ): void {
489
+ const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
490
+ let radioAddress = base;
491
+ let bitCursor: BitfieldCursor | undefined;
492
+ let bitStartAddress = base;
493
+
494
+ for (const field of struct.fields) {
495
+ if (field.type === 'bits') {
496
+ if (!bitCursor) {
497
+ bitStartAddress = radioAddress;
498
+ bitCursor = new BitfieldCursor(contents, memoryConfig, bitStartAddress);
499
+ }
500
+
501
+ const width = field.width ?? 1;
502
+
503
+ if (field.reserved) {
504
+ // Advance by reading existing bits (preserves memory)
505
+ bitCursor.read(width);
506
+ } else {
507
+ const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, values[field.id] ?? 0, 1);
508
+ bitCursor.write(width, encoded[0]);
509
+ }
510
+
511
+ continue;
512
+ }
513
+
514
+ if (bitCursor) {
515
+ radioAddress = bitCursor.nextRadioAddress();
516
+ bitCursor = undefined;
517
+ }
518
+
519
+ const length = fieldByteLength(field);
520
+
521
+ if (field.reserved) {
522
+ radioAddress += length;
523
+ continue;
524
+ }
525
+
526
+ const settingValue = values[field.id];
527
+
528
+ if (field.type === 'u16') {
529
+ const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, 2);
530
+ let value = 0;
531
+
532
+ if (field.value?.kind === 'tone') {
533
+ value = (encoded[0] & 0xff) | ((encoded[1] & 0xff) << 8);
534
+ } else {
535
+ value = (encoded[0] ?? 0) & 0xffff;
536
+ }
537
+
538
+ writeByte(contents, bufferOffsetFor(radioAddress, memoryConfig, contents), value & 0xff);
539
+ writeByte(contents, bufferOffsetFor(radioAddress + 1, memoryConfig, contents), (value >> 8) & 0xff);
540
+ } else {
541
+ const encoded = encodeRawValue(field.value, settingValue ?? (field.value?.kind === 'ascii' ? '' : 0), length);
542
+
543
+ for (let index = 0; index < length; index += 1) {
544
+ writeByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents), encoded[index]);
545
+ }
546
+ }
547
+
548
+ radioAddress += length;
549
+ }
550
+
551
+ if (bitCursor) {
552
+ bitCursor.nextRadioAddress();
553
+ }
554
+ }
555
+
556
+ function isStructInstanceEmpty(
557
+ struct: RadioMemoryMapStruct,
558
+ contents: Uint8Array,
559
+ memoryConfig: RadioMemoryConfig,
560
+ instanceIndex: number,
561
+ ): boolean {
562
+ if (!struct.emptyWhen) {
563
+ return false;
564
+ }
565
+
566
+ const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
567
+ const firstByte = readByte(contents, bufferOffsetFor(base, memoryConfig, contents));
568
+ return firstByte === struct.emptyWhen.equals;
569
+ }
570
+
571
+ function clearStructInstance(
572
+ struct: RadioMemoryMapStruct,
573
+ contents: Uint8Array,
574
+ memoryConfig: RadioMemoryConfig,
575
+ instanceIndex: number,
576
+ ): void {
577
+ const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
578
+ const length = struct.stride ?? 16;
579
+
580
+ for (let index = 0; index < length; index += 1) {
581
+ writeByte(contents, bufferOffsetFor(base + index, memoryConfig, contents), 0xff);
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Decode radio-wide settings from a memory image using a JSON memory map.
587
+ */
588
+ export function decodeMemoryMap(
589
+ memoryMap: RadioMemoryMap,
590
+ contents: Uint8Array,
591
+ memoryConfig: RadioMemoryConfig,
592
+ ): RadioSettings {
593
+ const settings: RadioSettings = {};
594
+
595
+ for (const struct of memoryMap.structs) {
596
+ const count = struct.count ?? 1;
597
+
598
+ if (count > 1) {
599
+ const items: RadioSettingValue[] = [];
600
+
601
+ for (let index = 0; index < count; index += 1) {
602
+ if (isStructInstanceEmpty(struct, contents, memoryConfig, index)) {
603
+ items.push(null);
604
+ } else {
605
+ items.push(decodeStruct(struct, contents, memoryConfig, index));
606
+ }
607
+ }
608
+
609
+ settings[struct.id] = items;
610
+ } else {
611
+ settings[struct.id] = decodeStruct(struct, contents, memoryConfig, 0);
612
+ }
613
+ }
614
+
615
+ return settings;
616
+ }
617
+
618
+ /**
619
+ * Encode radio-wide settings into an existing memory image in place.
620
+ * Returns the same buffer for convenience.
621
+ */
622
+ export function encodeMemoryMap(
623
+ memoryMap: RadioMemoryMap,
624
+ settings: RadioSettings,
625
+ contents: Uint8Array,
626
+ memoryConfig: RadioMemoryConfig,
627
+ ): Uint8Array {
628
+ for (const struct of memoryMap.structs) {
629
+ const count = struct.count ?? 1;
630
+ const value = settings[struct.id];
631
+
632
+ if (count > 1) {
633
+ const items = Array.isArray(value) ? value : [];
634
+
635
+ for (let index = 0; index < count; index += 1) {
636
+ const item = items[index];
637
+
638
+ if (item === null || item === undefined) {
639
+ if (struct.clearEmpty) {
640
+ clearStructInstance(struct, contents, memoryConfig, index);
641
+ }
642
+
643
+ continue;
644
+ }
645
+
646
+ const record =
647
+ item && typeof item === 'object' && !Array.isArray(item) ? (item as Record<string, RadioSettingValue>) : {};
648
+ encodeStruct(struct, record, contents, memoryConfig, index);
649
+ }
650
+ } else {
651
+ const record =
652
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, RadioSettingValue>) : {};
653
+ encodeStruct(struct, record, contents, memoryConfig, 0);
654
+ }
655
+ }
656
+
657
+ return contents;
658
+ }