@springfield/ham-radio-utils 4.5.1 → 4.7.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.
@@ -41,6 +41,90 @@ function radioToneToMapTone(tone: RadioTone | undefined): RadioMemoryMapToneValu
41
41
  return { mode: 'ctcss', value: Number(tone.tone) };
42
42
  }
43
43
 
44
+ function noneTone(): RadioTone {
45
+ return { tone: 0, type: RadioToneType.CTCSS };
46
+ }
47
+
48
+ function isNoneTone(tone: RadioTone | undefined): boolean {
49
+ return !tone || !tone.tone;
50
+ }
51
+
52
+ function structHasField(struct: { fields: { id: string }[] } | undefined, fieldId: string): boolean {
53
+ return Boolean(struct?.fields.some((field) => field.id === fieldId));
54
+ }
55
+
56
+ function transmitFrequencyFromRecord(record: Record<string, RadioSettingValue>, bindings: NonNullable<RadioMemoryMap['channelBindings']>): number {
57
+ const receiveFrequency = Number(record[bindings.receiveFrequency] ?? 0);
58
+ const offsetOrTransmit = Number(record[bindings.transmitFrequency] ?? 0);
59
+
60
+ if (record.split === true) {
61
+ return offsetOrTransmit;
62
+ }
63
+
64
+ if (record.duplex === '+') {
65
+ return receiveFrequency + offsetOrTransmit;
66
+ }
67
+
68
+ if (record.duplex === '-') {
69
+ return receiveFrequency - offsetOrTransmit;
70
+ }
71
+
72
+ // Kenwood stores simplex as duplex "" and offset 0. Empty duplex is not an absolute TX frequency.
73
+ if (record.duplex === '') {
74
+ return receiveFrequency;
75
+ }
76
+
77
+ if (record.duplex === undefined) {
78
+ return bindings.transmitFrequency === bindings.receiveFrequency ? receiveFrequency : offsetOrTransmit;
79
+ }
80
+
81
+ return offsetOrTransmit;
82
+ }
83
+
84
+ function applyKenwoodToneMode(
85
+ record: Record<string, RadioSettingValue>,
86
+ bindings: NonNullable<RadioMemoryMap['channelBindings']>,
87
+ ): { receiveTone: RadioTone; transmitTone: RadioTone } {
88
+ const hasToneModeBits = 'tone_mode' in record || 'ctcss_mode' in record || 'dtcs_mode' in record;
89
+
90
+ if (!hasToneModeBits) {
91
+ return {
92
+ receiveTone: toneToRadioTone(record[bindings.receiveTone]),
93
+ transmitTone: toneToRadioTone(record[bindings.transmitTone]),
94
+ };
95
+ }
96
+
97
+ if (record.tone_mode === true) {
98
+ return { receiveTone: noneTone(), transmitTone: toneToRadioTone(record[bindings.transmitTone]) };
99
+ }
100
+
101
+ if (record.ctcss_mode === true) {
102
+ const tone = toneToRadioTone(record[bindings.receiveTone]);
103
+ return { receiveTone: tone, transmitTone: tone };
104
+ }
105
+
106
+ if (record.dtcs_mode === true) {
107
+ const tone = toneToRadioTone(record.dtcs_code);
108
+ return { receiveTone: tone, transmitTone: tone };
109
+ }
110
+
111
+ return { receiveTone: noneTone(), transmitTone: noneTone() };
112
+ }
113
+
114
+ function kenwoodUsedFlag(receiveFrequency: number, transmitFrequency: number): number {
115
+ const frequency = transmitFrequency || receiveFrequency;
116
+
117
+ if (frequency < 150_000_000) {
118
+ return 0;
119
+ }
120
+
121
+ if (frequency < 400_000_000) {
122
+ return 1;
123
+ }
124
+
125
+ return 2;
126
+ }
127
+
44
128
  function asRecord(value: RadioSettingValue | undefined): Record<string, RadioSettingValue> | undefined {
45
129
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
46
130
  return undefined;
@@ -64,31 +148,41 @@ export function bindingsToChannels(
64
148
 
65
149
  const records = settings[bindings.records];
66
150
  const names = bindings.names ? settings[bindings.names] : undefined;
151
+ const extras = bindings.extras ? settings[bindings.extras] : undefined;
67
152
  const recordItems = Array.isArray(records) ? records : [];
68
153
  const nameItems = Array.isArray(names) ? names : [];
154
+ const extraItems = Array.isArray(extras) ? extras : [];
69
155
  const nameField = bindings.nameField ?? 'name';
70
156
  const boundFieldIds = new Set([
71
157
  bindings.receiveFrequency,
72
158
  bindings.transmitFrequency,
73
159
  bindings.receiveTone,
74
160
  bindings.transmitTone,
161
+ nameField,
75
162
  ]);
76
163
 
77
164
  const channels: RadioProgrammedChannel[] = [];
78
165
 
79
166
  for (let index = 0; index < recordItems.length; index += 1) {
80
167
  const record = asRecord(recordItems[index]);
168
+ const extraRecord = asRecord(extraItems[index]);
169
+
170
+ if (bindings.extras && extraItems.length > 0 && extraRecord === undefined) {
171
+ continue;
172
+ }
81
173
 
82
174
  if (!record) {
83
175
  continue;
84
176
  }
85
177
 
86
178
  const nameRecord = asRecord(nameItems[index]);
87
- const nameValue = nameRecord?.[nameField];
88
- const name = typeof nameValue === 'string' ? nameValue : '';
179
+ const nameFromTable = typeof nameRecord?.[nameField] === 'string' ? nameRecord[nameField] : '';
180
+ const nameFromRecord = typeof record[nameField] === 'string' ? record[nameField] : '';
181
+ const name = nameFromTable || nameFromRecord;
89
182
 
90
183
  const receiveFrequency = Number(record[bindings.receiveFrequency] ?? 0);
91
- const transmitFrequency = Number(record[bindings.transmitFrequency] ?? 0);
184
+ const transmitFrequency = transmitFrequencyFromRecord(record, bindings);
185
+ const tones = applyKenwoodToneMode(record, bindings);
92
186
 
93
187
  const channelSettings: RadioSettings = {};
94
188
 
@@ -100,6 +194,20 @@ export function bindingsToChannels(
100
194
  channelSettings[fieldId] = fieldValue;
101
195
  }
102
196
 
197
+ if (extraRecord) {
198
+ for (const [fieldId, fieldValue] of Object.entries(extraRecord)) {
199
+ if (fieldId.startsWith('_') || fieldId === 'used') {
200
+ continue;
201
+ }
202
+
203
+ channelSettings[fieldId] = fieldValue;
204
+ }
205
+
206
+ if (typeof extraRecord.lockout === 'boolean') {
207
+ channelSettings.skip = extraRecord.lockout ? 'S' : '';
208
+ }
209
+ }
210
+
103
211
  // Map Chirp lowpower index onto legacy transmitPower watts for UV-5R UI compatibility.
104
212
  if (typeof channelSettings.lowpower === 'number') {
105
213
  channelSettings.transmitPower = channelSettings.lowpower === 0 ? 5 : 1;
@@ -113,12 +221,16 @@ export function bindingsToChannels(
113
221
  channelSettings.skip = channelSettings.scan ? '' : 'S';
114
222
  }
115
223
 
224
+ if (typeof channelSettings.skip === 'boolean') {
225
+ channelSettings.skip = channelSettings.skip ? 'S' : '';
226
+ }
227
+
116
228
  const radioChannel: RadioChannel = {
117
229
  name,
118
230
  receiveFrequency: Frequency(receiveFrequency),
119
231
  transmitFrequency: Frequency(transmitFrequency),
120
- receiveTone: toneToRadioTone(record[bindings.receiveTone]),
121
- transmitTone: toneToRadioTone(record[bindings.transmitTone]),
232
+ receiveTone: tones.receiveTone,
233
+ transmitTone: tones.transmitTone,
122
234
  };
123
235
 
124
236
  channels.push({
@@ -149,6 +261,10 @@ export function settingsWithoutChannels(memoryMap: RadioMemoryMap, settings: Rad
149
261
  delete next[bindings.names];
150
262
  }
151
263
 
264
+ if (bindings.extras) {
265
+ delete next[bindings.extras];
266
+ }
267
+
152
268
  return next;
153
269
  }
154
270
 
@@ -164,11 +280,13 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
164
280
 
165
281
  const recordsStruct = memoryMap.structs.find((struct) => struct.id === bindings.records);
166
282
  const namesStruct = bindings.names ? memoryMap.structs.find((struct) => struct.id === bindings.names) : undefined;
283
+ const extrasStruct = bindings.extras ? memoryMap.structs.find((struct) => struct.id === bindings.extras) : undefined;
167
284
  const count = recordsStruct?.count ?? 0;
168
285
  const nameField = bindings.nameField ?? 'name';
169
286
 
170
287
  const records: RadioSettingValue[] = Array.from({ length: count }, () => null);
171
288
  const names: RadioSettingValue[] = Array.from({ length: count }, () => null);
289
+ const extras: RadioSettingValue[] = Array.from({ length: count }, () => null);
172
290
 
173
291
  for (const programmed of program.channels) {
174
292
  const index = programmed.channelNumber;
@@ -183,13 +301,67 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
183
301
 
184
302
  const channel = programmed.radioChannel;
185
303
  const channelSettings = programmed.settings ?? {};
304
+ const receiveFrequency = Number(channel.receiveFrequency);
305
+ const transmitFrequency = Number(channel.transmitFrequency);
306
+ let offset = transmitFrequency;
307
+ let duplex: RadioSettingValue = '';
308
+ let split = false;
309
+
310
+ if (transmitFrequency === receiveFrequency) {
311
+ offset = 0;
312
+ duplex = '';
313
+ } else if (transmitFrequency > receiveFrequency) {
314
+ offset = transmitFrequency - receiveFrequency;
315
+ duplex = '+';
316
+ } else {
317
+ offset = receiveFrequency - transmitFrequency;
318
+ duplex = '-';
319
+ }
320
+
321
+ if (channelSettings.split === true) {
322
+ split = true;
323
+ duplex = '';
324
+ offset = transmitFrequency;
325
+ }
326
+
327
+ const usesOffset =
328
+ 'duplex' in channelSettings || extrasStruct || structHasField(recordsStruct, 'duplex');
329
+ const usesKenwoodToneBits =
330
+ 'tone_mode' in channelSettings ||
331
+ 'ctcss_mode' in channelSettings ||
332
+ extrasStruct ||
333
+ structHasField(recordsStruct, 'tone_mode') ||
334
+ structHasField(recordsStruct, 'ctcss_mode');
335
+
186
336
  const record: Record<string, RadioSettingValue> = {
187
- [bindings.receiveFrequency]: channel.receiveFrequency,
188
- [bindings.transmitFrequency]: channel.transmitFrequency,
337
+ [bindings.receiveFrequency]: receiveFrequency,
338
+ [bindings.transmitFrequency]: usesOffset ? offset : transmitFrequency,
189
339
  [bindings.receiveTone]: radioToneToMapTone(channel.receiveTone),
190
340
  [bindings.transmitTone]: radioToneToMapTone(channel.transmitTone),
191
341
  };
192
342
 
343
+ if (usesOffset) {
344
+ record.duplex = typeof channelSettings.duplex === 'string' ? channelSettings.duplex : duplex;
345
+ record.split = typeof channelSettings.split === 'boolean' ? channelSettings.split : split;
346
+ record.offset = offset;
347
+ }
348
+
349
+ if (usesKenwoodToneBits) {
350
+ record.tone_mode = false;
351
+ record.ctcss_mode = false;
352
+ record.dtcs_mode = false;
353
+ record.cross_mode = false;
354
+
355
+ if (channel.transmitTone?.type === RadioToneType.DCS || channel.receiveTone?.type === RadioToneType.DCS) {
356
+ record.dtcs_mode = true;
357
+ record.dtcs_code = radioToneToMapTone(channel.transmitTone?.type === RadioToneType.DCS ? channel.transmitTone : channel.receiveTone);
358
+ } else if (!isNoneTone(channel.receiveTone) && !isNoneTone(channel.transmitTone)) {
359
+ record.ctcss_mode = true;
360
+ } else if (!isNoneTone(channel.transmitTone)) {
361
+ record.tone_mode = true;
362
+ }
363
+ }
364
+
193
365
  for (const [key, value] of Object.entries(channelSettings)) {
194
366
  if (key === 'transmitPower') {
195
367
  record.lowpower = value === 5 || value === 4 || value === 0 ? 0 : 1;
@@ -198,11 +370,13 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
198
370
 
199
371
  if (key === 'mode') {
200
372
  record.wide = value === 'FM';
373
+ record.mode = value;
201
374
  continue;
202
375
  }
203
376
 
204
377
  if (key === 'skip') {
205
378
  record.scan = value !== 'S';
379
+ record.skip = value === 'S';
206
380
  continue;
207
381
  }
208
382
 
@@ -222,7 +396,20 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
222
396
  }
223
397
 
224
398
  records[index] = record;
225
- names[index] = { [nameField]: channel.name ?? '' };
399
+
400
+ if (namesStruct) {
401
+ names[index] = { [nameField]: channel.name ?? '' };
402
+ } else {
403
+ record[nameField] = channel.name ?? '';
404
+ }
405
+
406
+ if (extrasStruct) {
407
+ extras[index] = {
408
+ used: kenwoodUsedFlag(receiveFrequency, transmitFrequency),
409
+ lockout: channelSettings.skip === 'S' || channelSettings.lockout === true,
410
+ group: typeof channelSettings.group === 'number' ? channelSettings.group : 0,
411
+ };
412
+ }
226
413
  }
227
414
 
228
415
  const bag: RadioSettings = { ...program.settings, [bindings.records]: records };
@@ -231,6 +418,10 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
231
418
  bag[bindings.names] = names;
232
419
  }
233
420
 
421
+ if (bindings.extras && extrasStruct) {
422
+ bag[bindings.extras] = extras;
423
+ }
424
+
234
425
  return bag;
235
426
  }
236
427
 
@@ -78,6 +78,52 @@ function writeByte(contents: Uint8Array, offset: number, value: number): void {
78
78
  contents[offset] = value & 0xff;
79
79
  }
80
80
 
81
+ function decodeInteger(raw: number | number[]): number {
82
+ if (typeof raw === 'number') {
83
+ return raw;
84
+ }
85
+
86
+ let value = 0;
87
+
88
+ for (let index = 0; index < raw.length; index += 1) {
89
+ value += raw[index] * 256 ** index;
90
+ }
91
+
92
+ return value;
93
+ }
94
+
95
+ function encodeInteger(value: RadioSettingValue, byteLength: number): number[] {
96
+ let numeric = typeof value === 'number' ? value : 0;
97
+ const bytes: number[] = [];
98
+
99
+ for (let index = 0; index < byteLength; index += 1) {
100
+ bytes.push(numeric & 0xff);
101
+ numeric = Math.floor(numeric / 256);
102
+ }
103
+
104
+ return bytes;
105
+ }
106
+
107
+ /**
108
+ * Radio address of repeated-struct instance `index`, including Kenwood-style
109
+ * grouped stride (N records + pad bytes per clone block).
110
+ */
111
+ export function structInstanceAddress(struct: RadioMemoryMapStruct, instanceIndex: number): number {
112
+ const seek = parseSeekAddress(struct.seek);
113
+ const stride = struct.stride ?? 0;
114
+ const groupSize = struct.groupSize;
115
+
116
+ if (!groupSize) {
117
+ return seek + instanceIndex * stride;
118
+ }
119
+
120
+ const groupPad = struct.groupPad ?? 0;
121
+ const groupIndex = Math.floor(instanceIndex / groupSize);
122
+ const indexInGroup = instanceIndex % groupSize;
123
+
124
+ return seek + groupIndex * (stride * groupSize + groupPad) + indexInGroup * stride;
125
+ }
126
+
81
127
  function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number | number[]): RadioSettingValue {
82
128
  if (!kind) {
83
129
  return typeof raw === 'number' ? raw : raw;
@@ -85,7 +131,7 @@ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number |
85
131
 
86
132
  switch (kind.kind) {
87
133
  case 'integer':
88
- return typeof raw === 'number' ? raw : raw[0];
134
+ return decodeInteger(raw);
89
135
  case 'boolean':
90
136
  return (typeof raw === 'number' ? raw : raw[0]) !== 0;
91
137
  case 'enum': {
@@ -183,6 +229,16 @@ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number |
183
229
  const code = kind.values[index] ?? 0;
184
230
  return { mode: 'dcs', code, polarity };
185
231
  }
232
+ case 'ctcss-index': {
233
+ const index = decodeInteger(raw);
234
+ const tenths = kind.values[index] ?? kind.values[0] ?? 0;
235
+ return { mode: 'ctcss', value: tenths };
236
+ }
237
+ case 'dcs-index': {
238
+ const index = decodeInteger(raw);
239
+ const code = kind.values[index] ?? 0;
240
+ return { mode: 'dcs', code, polarity: 'N' };
241
+ }
186
242
  default: {
187
243
  const exhaustive: never = kind;
188
244
  return exhaustive;
@@ -203,8 +259,7 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
203
259
 
204
260
  switch (kind.kind) {
205
261
  case 'integer': {
206
- bytes[0] = typeof value === 'number' ? value & 0xff : 0;
207
- return bytes;
262
+ return encodeInteger(value, byteLength);
208
263
  }
209
264
  case 'boolean': {
210
265
  bytes[0] = value ? 1 : 0;
@@ -217,9 +272,10 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
217
272
  }
218
273
  case 'ascii': {
219
274
  const text = typeof value === 'string' ? value : '';
275
+ const pad = kind.pad ?? 0xff;
220
276
 
221
277
  for (let index = 0; index < byteLength; index += 1) {
222
- bytes[index] = index < text.length ? (text.codePointAt(index) ?? 0xff) : 0xff;
278
+ bytes[index] = index < text.length ? (text.codePointAt(index) ?? pad) : pad;
223
279
  }
224
280
 
225
281
  return bytes;
@@ -309,6 +365,18 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
309
365
  bytes[1] = (rawValue >> 8) & 0xff;
310
366
  return bytes;
311
367
  }
368
+ case 'ctcss-index': {
369
+ const tone = value && typeof value === 'object' && !Array.isArray(value) ? (value as { mode?: string; value?: number }) : undefined;
370
+ const tenths = tone?.mode === 'ctcss' && typeof tone.value === 'number' ? tone.value : 0;
371
+ const index = kind.values.indexOf(tenths);
372
+ return encodeInteger(index >= 0 ? index : 0, byteLength);
373
+ }
374
+ case 'dcs-index': {
375
+ const tone = value && typeof value === 'object' && !Array.isArray(value) ? (value as { mode?: string; code?: number }) : undefined;
376
+ const code = tone?.mode === 'dcs' && typeof tone.code === 'number' ? tone.code : 0;
377
+ const index = kind.values.indexOf(code);
378
+ return encodeInteger(index >= 0 ? index : 0, byteLength);
379
+ }
312
380
  default: {
313
381
  const exhaustive: never = kind;
314
382
  return exhaustive;
@@ -317,6 +385,10 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
317
385
  }
318
386
 
319
387
  function fieldByteLength(field: RadioMemoryMapField): number {
388
+ if (field.type === 'u32') {
389
+ return 4;
390
+ }
391
+
320
392
  if (field.type === 'u16') {
321
393
  return 2;
322
394
  }
@@ -433,7 +505,7 @@ function decodeStruct(
433
505
  memoryConfig: RadioMemoryConfig,
434
506
  instanceIndex: number,
435
507
  ): Record<string, RadioSettingValue> {
436
- const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
508
+ const base = structInstanceAddress(struct, instanceIndex);
437
509
  const result: Record<string, RadioSettingValue> = {};
438
510
  let radioAddress = base;
439
511
  let bitCursor: BitfieldCursor | undefined;
@@ -465,7 +537,7 @@ function decodeStruct(
465
537
  bytes.push(readByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents)));
466
538
  }
467
539
 
468
- if (field.type === 'u16') {
540
+ if (field.type === 'u16' || field.type === 'u32') {
469
541
  if (!field.reserved) {
470
542
  result[field.id] = decodeRawValue(field.value ?? { kind: 'integer' }, bytes);
471
543
  }
@@ -486,7 +558,7 @@ function encodeStruct(
486
558
  memoryConfig: RadioMemoryConfig,
487
559
  instanceIndex: number,
488
560
  ): void {
489
- const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
561
+ const base = structInstanceAddress(struct, instanceIndex);
490
562
  let radioAddress = base;
491
563
  let bitCursor: BitfieldCursor | undefined;
492
564
  let bitStartAddress = base;
@@ -525,18 +597,12 @@ function encodeStruct(
525
597
 
526
598
  const settingValue = values[field.id];
527
599
 
528
- if (field.type === 'u16') {
529
- const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, 2);
530
- let value = 0;
600
+ if (field.type === 'u16' || field.type === 'u32') {
601
+ const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, length);
531
602
 
532
- if (field.value?.kind === 'tone') {
533
- value = (encoded[0] & 0xff) | ((encoded[1] & 0xff) << 8);
534
- } else {
535
- value = (encoded[0] ?? 0) & 0xffff;
603
+ for (let index = 0; index < length; index += 1) {
604
+ writeByte(contents, bufferOffsetFor(radioAddress + index, memoryConfig, contents), encoded[index] ?? 0);
536
605
  }
537
-
538
- writeByte(contents, bufferOffsetFor(radioAddress, memoryConfig, contents), value & 0xff);
539
- writeByte(contents, bufferOffsetFor(radioAddress + 1, memoryConfig, contents), (value >> 8) & 0xff);
540
606
  } else {
541
607
  const encoded = encodeRawValue(field.value, settingValue ?? (field.value?.kind === 'ascii' ? '' : 0), length);
542
608
 
@@ -563,7 +629,7 @@ function isStructInstanceEmpty(
563
629
  return false;
564
630
  }
565
631
 
566
- const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
632
+ const base = structInstanceAddress(struct, instanceIndex);
567
633
  const firstByte = readByte(contents, bufferOffsetFor(base, memoryConfig, contents));
568
634
  return firstByte === struct.emptyWhen.equals;
569
635
  }
@@ -574,7 +640,7 @@ function clearStructInstance(
574
640
  memoryConfig: RadioMemoryConfig,
575
641
  instanceIndex: number,
576
642
  ): void {
577
- const base = parseSeekAddress(struct.seek) + instanceIndex * (struct.stride ?? 0);
643
+ const base = structInstanceAddress(struct, instanceIndex);
578
644
  const length = struct.stride ?? 16;
579
645
 
580
646
  for (let index = 0; index < length; index += 1) {
@@ -0,0 +1,96 @@
1
+ import type {
2
+ RadioCodec,
3
+ RadioMemory,
4
+ RadioMemoryConfig,
5
+ RadioMemoryMap,
6
+ RadioModelId,
7
+ RadioProgram,
8
+ } from '@springfield/ham-radio-api';
9
+ import type { ILogLayer } from 'loglayer';
10
+ import { decodeRadioProgram, encodeRadioProgram } from './memory-map-channels.js';
11
+
12
+ /**
13
+ * Compute the sparse image size that covers every configured memory segment.
14
+ */
15
+ export function memoryImageSize(memoryConfig: RadioMemoryConfig): number {
16
+ const ends = Object.values(memoryConfig.segments).map((segment) => segment.endAddress);
17
+ return Math.max(...ends) + 1;
18
+ }
19
+
20
+ /**
21
+ * Create a Chirp-like empty EEPROM image filled with 0xFF.
22
+ */
23
+ export function createEmptyMemoryImage(totalSize: number): Uint8Array {
24
+ const memory = new Uint8Array(totalSize);
25
+ memory.fill(0xff);
26
+ return memory;
27
+ }
28
+
29
+ export interface MemoryMapRadioCodecOptions {
30
+ radioModel: RadioModelId;
31
+ memoryMap: RadioMemoryMap;
32
+ memoryConfig: RadioMemoryConfig;
33
+ logger?: ILogLayer;
34
+ }
35
+
36
+ /**
37
+ * Generic RadioCodec driven entirely by a JSON memory map + memoryConfig.
38
+ * Radio modules that ship DSL JSON do not need TypeScript codecs.
39
+ */
40
+ export class MemoryMapRadioCodec implements RadioCodec {
41
+ private readonly radioModel: RadioModelId;
42
+ private readonly memoryMap: RadioMemoryMap;
43
+ private readonly memoryConfig: RadioMemoryConfig;
44
+ private readonly logger: ILogLayer | undefined;
45
+
46
+ constructor(options: MemoryMapRadioCodecOptions) {
47
+ this.radioModel = options.radioModel;
48
+ this.memoryMap = options.memoryMap;
49
+ this.memoryConfig = options.memoryConfig;
50
+ this.logger = options.logger;
51
+ }
52
+
53
+ decode(memory: RadioMemory): RadioProgram {
54
+ try {
55
+ return decodeRadioProgram(this.memoryMap, memory.contents, this.memoryConfig);
56
+ } catch (error) {
57
+ this.logger?.withError(error).warn('Failed to decode radio program from memory map');
58
+ return { channels: [], settings: {} };
59
+ }
60
+ }
61
+
62
+ encode(program: RadioProgram, memory: RadioMemory): RadioMemory {
63
+ const totalSize = memoryImageSize(this.memoryConfig);
64
+ const contents =
65
+ memory.contents.length > 0 ? new Uint8Array(memory.contents) : createEmptyMemoryImage(totalSize);
66
+
67
+ if (contents.length < totalSize) {
68
+ const expanded = createEmptyMemoryImage(totalSize);
69
+ expanded.set(contents);
70
+ try {
71
+ encodeRadioProgram(this.memoryMap, program, expanded, this.memoryConfig);
72
+ } catch (error) {
73
+ this.logger?.withError(error).warn('Failed to encode radio program into memory map');
74
+ }
75
+
76
+ this.logger?.debug(`Memory size: ${expanded.length} bytes`);
77
+ return { contents: expanded, radioModel: this.radioModel };
78
+ }
79
+
80
+ try {
81
+ encodeRadioProgram(this.memoryMap, program, contents, this.memoryConfig);
82
+ } catch (error) {
83
+ this.logger?.withError(error).warn('Failed to encode radio program into memory map');
84
+ }
85
+
86
+ this.logger?.debug(`Memory size: ${contents.length} bytes`);
87
+ return { contents, radioModel: this.radioModel };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create a memory-map codec from hydrated radio config fields.
93
+ */
94
+ export function createMemoryMapCodec(options: MemoryMapRadioCodecOptions): RadioCodec {
95
+ return new MemoryMapRadioCodec(options);
96
+ }
@@ -55,6 +55,10 @@ export function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemory
55
55
  if (memoryMap.channelBindings.names) {
56
56
  channelStructIds.add(memoryMap.channelBindings.names);
57
57
  }
58
+
59
+ if (memoryMap.channelBindings.extras) {
60
+ channelStructIds.add(memoryMap.channelBindings.extras);
61
+ }
58
62
  }
59
63
 
60
64
  for (const struct of memoryMap.structs) {
@@ -105,6 +109,7 @@ export function collectChannelMemoryMapUiFields(memoryMap: RadioMemoryMap): Radi
105
109
  }
106
110
 
107
111
  const struct = memoryMap.structs.find((entry) => entry.id === bindings.records);
112
+ const extrasStruct = bindings.extras ? memoryMap.structs.find((entry) => entry.id === bindings.extras) : undefined;
108
113
 
109
114
  if (!struct) {
110
115
  return [];
@@ -113,18 +118,24 @@ export function collectChannelMemoryMapUiFields(memoryMap: RadioMemoryMap): Radi
113
118
  const boundIds = channelBoundFieldIds(memoryMap);
114
119
  const fields: RadioMemoryMapUiField[] = [];
115
120
 
116
- for (const field of struct.fields) {
117
- if (field.reserved || !field.ui || boundIds.has(field.id)) {
121
+ for (const source of [struct, extrasStruct]) {
122
+ if (!source) {
118
123
  continue;
119
124
  }
120
125
 
121
- fields.push({
122
- path: field.id,
123
- structId: struct.id,
124
- fieldId: field.id,
125
- ui: field.ui,
126
- value: field.value,
127
- });
126
+ for (const field of source.fields) {
127
+ if (field.reserved || !field.ui || boundIds.has(field.id)) {
128
+ continue;
129
+ }
130
+
131
+ fields.push({
132
+ path: field.id,
133
+ structId: source.id,
134
+ fieldId: field.id,
135
+ ui: field.ui,
136
+ value: field.value,
137
+ });
138
+ }
128
139
  }
129
140
 
130
141
  return fields;