@springfield/ham-radio-utils 4.6.0 → 4.8.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,115 @@ 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
+
128
+ /** TM-D710 chmap band codes: 0=118 MHz, 5=144, 6=200, 7=300, 8=400, 9=800. */
129
+ function kenwoodChmapBand(receiveFrequency: number): number {
130
+ if (receiveFrequency < 136_000_000) {
131
+ return 0;
132
+ }
133
+
134
+ if (receiveFrequency < 200_000_000) {
135
+ return 5;
136
+ }
137
+
138
+ if (receiveFrequency < 300_000_000) {
139
+ return 6;
140
+ }
141
+
142
+ if (receiveFrequency < 400_000_000) {
143
+ return 7;
144
+ }
145
+
146
+ if (receiveFrequency < 800_000_000) {
147
+ return 8;
148
+ }
149
+
150
+ return 9;
151
+ }
152
+
44
153
  function asRecord(value: RadioSettingValue | undefined): Record<string, RadioSettingValue> | undefined {
45
154
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
46
155
  return undefined;
@@ -64,31 +173,41 @@ export function bindingsToChannels(
64
173
 
65
174
  const records = settings[bindings.records];
66
175
  const names = bindings.names ? settings[bindings.names] : undefined;
176
+ const extras = bindings.extras ? settings[bindings.extras] : undefined;
67
177
  const recordItems = Array.isArray(records) ? records : [];
68
178
  const nameItems = Array.isArray(names) ? names : [];
179
+ const extraItems = Array.isArray(extras) ? extras : [];
69
180
  const nameField = bindings.nameField ?? 'name';
70
181
  const boundFieldIds = new Set([
71
182
  bindings.receiveFrequency,
72
183
  bindings.transmitFrequency,
73
184
  bindings.receiveTone,
74
185
  bindings.transmitTone,
186
+ nameField,
75
187
  ]);
76
188
 
77
189
  const channels: RadioProgrammedChannel[] = [];
78
190
 
79
191
  for (let index = 0; index < recordItems.length; index += 1) {
80
192
  const record = asRecord(recordItems[index]);
193
+ const extraRecord = asRecord(extraItems[index]);
194
+
195
+ if (bindings.extras && extraItems.length > 0 && extraRecord === undefined) {
196
+ continue;
197
+ }
81
198
 
82
199
  if (!record) {
83
200
  continue;
84
201
  }
85
202
 
86
203
  const nameRecord = asRecord(nameItems[index]);
87
- const nameValue = nameRecord?.[nameField];
88
- const name = typeof nameValue === 'string' ? nameValue : '';
204
+ const nameFromTable = typeof nameRecord?.[nameField] === 'string' ? nameRecord[nameField] : '';
205
+ const nameFromRecord = typeof record[nameField] === 'string' ? record[nameField] : '';
206
+ const name = nameFromTable || nameFromRecord;
89
207
 
90
208
  const receiveFrequency = Number(record[bindings.receiveFrequency] ?? 0);
91
- const transmitFrequency = Number(record[bindings.transmitFrequency] ?? 0);
209
+ const transmitFrequency = transmitFrequencyFromRecord(record, bindings);
210
+ const tones = applyKenwoodToneMode(record, bindings);
92
211
 
93
212
  const channelSettings: RadioSettings = {};
94
213
 
@@ -100,6 +219,20 @@ export function bindingsToChannels(
100
219
  channelSettings[fieldId] = fieldValue;
101
220
  }
102
221
 
222
+ if (extraRecord) {
223
+ for (const [fieldId, fieldValue] of Object.entries(extraRecord)) {
224
+ if (fieldId.startsWith('_') || fieldId === 'used') {
225
+ continue;
226
+ }
227
+
228
+ channelSettings[fieldId] = fieldValue;
229
+ }
230
+
231
+ if (typeof extraRecord.lockout === 'boolean') {
232
+ channelSettings.skip = extraRecord.lockout ? 'S' : '';
233
+ }
234
+ }
235
+
103
236
  // Map Chirp lowpower index onto legacy transmitPower watts for UV-5R UI compatibility.
104
237
  if (typeof channelSettings.lowpower === 'number') {
105
238
  channelSettings.transmitPower = channelSettings.lowpower === 0 ? 5 : 1;
@@ -113,12 +246,16 @@ export function bindingsToChannels(
113
246
  channelSettings.skip = channelSettings.scan ? '' : 'S';
114
247
  }
115
248
 
249
+ if (typeof channelSettings.skip === 'boolean') {
250
+ channelSettings.skip = channelSettings.skip ? 'S' : '';
251
+ }
252
+
116
253
  const radioChannel: RadioChannel = {
117
254
  name,
118
255
  receiveFrequency: Frequency(receiveFrequency),
119
256
  transmitFrequency: Frequency(transmitFrequency),
120
- receiveTone: toneToRadioTone(record[bindings.receiveTone]),
121
- transmitTone: toneToRadioTone(record[bindings.transmitTone]),
257
+ receiveTone: tones.receiveTone,
258
+ transmitTone: tones.transmitTone,
122
259
  };
123
260
 
124
261
  channels.push({
@@ -149,6 +286,10 @@ export function settingsWithoutChannels(memoryMap: RadioMemoryMap, settings: Rad
149
286
  delete next[bindings.names];
150
287
  }
151
288
 
289
+ if (bindings.extras) {
290
+ delete next[bindings.extras];
291
+ }
292
+
152
293
  return next;
153
294
  }
154
295
 
@@ -164,11 +305,13 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
164
305
 
165
306
  const recordsStruct = memoryMap.structs.find((struct) => struct.id === bindings.records);
166
307
  const namesStruct = bindings.names ? memoryMap.structs.find((struct) => struct.id === bindings.names) : undefined;
308
+ const extrasStruct = bindings.extras ? memoryMap.structs.find((struct) => struct.id === bindings.extras) : undefined;
167
309
  const count = recordsStruct?.count ?? 0;
168
310
  const nameField = bindings.nameField ?? 'name';
169
311
 
170
312
  const records: RadioSettingValue[] = Array.from({ length: count }, () => null);
171
313
  const names: RadioSettingValue[] = Array.from({ length: count }, () => null);
314
+ const extras: RadioSettingValue[] = Array.from({ length: count }, () => null);
172
315
 
173
316
  for (const programmed of program.channels) {
174
317
  const index = programmed.channelNumber;
@@ -183,13 +326,67 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
183
326
 
184
327
  const channel = programmed.radioChannel;
185
328
  const channelSettings = programmed.settings ?? {};
329
+ const receiveFrequency = Number(channel.receiveFrequency);
330
+ const transmitFrequency = Number(channel.transmitFrequency);
331
+ let offset = transmitFrequency;
332
+ let duplex: RadioSettingValue = '';
333
+ let split = false;
334
+
335
+ if (transmitFrequency === receiveFrequency) {
336
+ offset = 0;
337
+ duplex = '';
338
+ } else if (transmitFrequency > receiveFrequency) {
339
+ offset = transmitFrequency - receiveFrequency;
340
+ duplex = '+';
341
+ } else {
342
+ offset = receiveFrequency - transmitFrequency;
343
+ duplex = '-';
344
+ }
345
+
346
+ if (channelSettings.split === true) {
347
+ split = true;
348
+ duplex = '';
349
+ offset = transmitFrequency;
350
+ }
351
+
352
+ const usesOffset =
353
+ 'duplex' in channelSettings || extrasStruct || structHasField(recordsStruct, 'duplex');
354
+ const usesKenwoodToneBits =
355
+ 'tone_mode' in channelSettings ||
356
+ 'ctcss_mode' in channelSettings ||
357
+ extrasStruct ||
358
+ structHasField(recordsStruct, 'tone_mode') ||
359
+ structHasField(recordsStruct, 'ctcss_mode');
360
+
186
361
  const record: Record<string, RadioSettingValue> = {
187
- [bindings.receiveFrequency]: channel.receiveFrequency,
188
- [bindings.transmitFrequency]: channel.transmitFrequency,
362
+ [bindings.receiveFrequency]: receiveFrequency,
363
+ [bindings.transmitFrequency]: usesOffset ? offset : transmitFrequency,
189
364
  [bindings.receiveTone]: radioToneToMapTone(channel.receiveTone),
190
365
  [bindings.transmitTone]: radioToneToMapTone(channel.transmitTone),
191
366
  };
192
367
 
368
+ if (usesOffset) {
369
+ record.duplex = typeof channelSettings.duplex === 'string' ? channelSettings.duplex : duplex;
370
+ record.split = typeof channelSettings.split === 'boolean' ? channelSettings.split : split;
371
+ record.offset = offset;
372
+ }
373
+
374
+ if (usesKenwoodToneBits) {
375
+ record.tone_mode = false;
376
+ record.ctcss_mode = false;
377
+ record.dtcs_mode = false;
378
+ record.cross_mode = false;
379
+
380
+ if (channel.transmitTone?.type === RadioToneType.DCS || channel.receiveTone?.type === RadioToneType.DCS) {
381
+ record.dtcs_mode = true;
382
+ record.dtcs_code = radioToneToMapTone(channel.transmitTone?.type === RadioToneType.DCS ? channel.transmitTone : channel.receiveTone);
383
+ } else if (!isNoneTone(channel.receiveTone) && !isNoneTone(channel.transmitTone)) {
384
+ record.ctcss_mode = true;
385
+ } else if (!isNoneTone(channel.transmitTone)) {
386
+ record.tone_mode = true;
387
+ }
388
+ }
389
+
193
390
  for (const [key, value] of Object.entries(channelSettings)) {
194
391
  if (key === 'transmitPower') {
195
392
  record.lowpower = value === 5 || value === 4 || value === 0 ? 0 : 1;
@@ -198,11 +395,13 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
198
395
 
199
396
  if (key === 'mode') {
200
397
  record.wide = value === 'FM';
398
+ record.mode = value;
201
399
  continue;
202
400
  }
203
401
 
204
402
  if (key === 'skip') {
205
403
  record.scan = value !== 'S';
404
+ record.skip = value === 'S';
206
405
  continue;
207
406
  }
208
407
 
@@ -222,7 +421,26 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
222
421
  }
223
422
 
224
423
  records[index] = record;
225
- names[index] = { [nameField]: channel.name ?? '' };
424
+
425
+ if (namesStruct) {
426
+ names[index] = { [nameField]: channel.name ?? '' };
427
+ } else {
428
+ record[nameField] = channel.name ?? '';
429
+ }
430
+
431
+ if (extrasStruct) {
432
+ const extraRecord: Record<string, RadioSettingValue> = {
433
+ used: kenwoodUsedFlag(receiveFrequency, transmitFrequency),
434
+ lockout: channelSettings.skip === 'S' || channelSettings.lockout === true,
435
+ group: typeof channelSettings.group === 'number' ? channelSettings.group : 0,
436
+ };
437
+
438
+ if (structHasField(extrasStruct, 'band')) {
439
+ extraRecord.band = typeof channelSettings.band === 'number' ? channelSettings.band : kenwoodChmapBand(receiveFrequency);
440
+ }
441
+
442
+ extras[index] = extraRecord;
443
+ }
226
444
  }
227
445
 
228
446
  const bag: RadioSettings = { ...program.settings, [bindings.records]: records };
@@ -231,6 +449,10 @@ export function programToChannelSettings(memoryMap: RadioMemoryMap, program: Rad
231
449
  bag[bindings.names] = names;
232
450
  }
233
451
 
452
+ if (bindings.extras && extrasStruct) {
453
+ bag[bindings.extras] = extras;
454
+ }
455
+
234
456
  return bag;
235
457
  }
236
458
 
@@ -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) {
@@ -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;
@@ -40,7 +40,8 @@
40
40
  "receiveFrequency": { "type": "string", "minLength": 1 },
41
41
  "transmitFrequency": { "type": "string", "minLength": 1 },
42
42
  "receiveTone": { "type": "string", "minLength": 1 },
43
- "transmitTone": { "type": "string", "minLength": 1 }
43
+ "transmitTone": { "type": "string", "minLength": 1 },
44
+ "extras": { "type": "string", "minLength": 1 }
44
45
  },
45
46
  "additionalProperties": false
46
47
  },
@@ -105,7 +106,8 @@
105
106
  "required": ["kind", "length"],
106
107
  "properties": {
107
108
  "kind": { "const": "ascii" },
108
- "length": { "type": "integer", "minimum": 1 }
109
+ "length": { "type": "integer", "minimum": 1 },
110
+ "pad": { "type": "integer", "minimum": 0, "maximum": 255 }
109
111
  },
110
112
  "additionalProperties": false
111
113
  },
@@ -162,6 +164,32 @@
162
164
  "reverseOffset": { "type": "integer" }
163
165
  },
164
166
  "additionalProperties": false
167
+ },
168
+ {
169
+ "type": "object",
170
+ "required": ["kind", "values"],
171
+ "properties": {
172
+ "kind": { "const": "ctcss-index" },
173
+ "values": {
174
+ "type": "array",
175
+ "items": { "type": "integer" },
176
+ "minItems": 1
177
+ }
178
+ },
179
+ "additionalProperties": false
180
+ },
181
+ {
182
+ "type": "object",
183
+ "required": ["kind", "values"],
184
+ "properties": {
185
+ "kind": { "const": "dcs-index" },
186
+ "values": {
187
+ "type": "array",
188
+ "items": { "type": "integer" },
189
+ "minItems": 1
190
+ }
191
+ },
192
+ "additionalProperties": false
165
193
  }
166
194
  ]
167
195
  },
@@ -172,7 +200,7 @@
172
200
  "id": { "type": "string", "minLength": 1 },
173
201
  "type": {
174
202
  "type": "string",
175
- "enum": ["u8", "u16", "bits", "char"]
203
+ "enum": ["u8", "u16", "u32", "bits", "char"]
176
204
  },
177
205
  "width": { "type": "integer", "minimum": 1, "maximum": 8 },
178
206
  "reserved": { "type": "boolean" },
@@ -194,6 +222,8 @@
194
222
  },
195
223
  "count": { "type": "integer", "minimum": 1 },
196
224
  "stride": { "type": "integer", "minimum": 1 },
225
+ "groupSize": { "type": "integer", "minimum": 1 },
226
+ "groupPad": { "type": "integer", "minimum": 0 },
197
227
  "emptyWhen": { "$ref": "#/definitions/emptyWhen" },
198
228
  "clearEmpty": { "type": "boolean" }
199
229
  },