@springfield/ham-radio-utils 4.2.0 → 4.4.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.
@@ -143,6 +143,46 @@ function decodeRawValue(kind: RadioMemoryMapValueKind | undefined, raw: number |
143
143
 
144
144
  return value;
145
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
+ }
146
186
  default: {
147
187
  const exhaustive: never = kind;
148
188
  return exhaustive;
@@ -225,6 +265,50 @@ function encodeRawValue(kind: RadioMemoryMapValueKind | undefined, value: RadioS
225
265
 
226
266
  return bytes;
227
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
+ }
228
312
  default: {
229
313
  const exhaustive: never = kind;
230
314
  return exhaustive;
@@ -245,11 +329,16 @@ function fieldByteLength(field: RadioMemoryMapField): number {
245
329
  field.value?.kind === 'ascii' ||
246
330
  field.value?.kind === 'digits' ||
247
331
  field.value?.kind === 'dtmf' ||
248
- field.value?.kind === 'bbcd'
332
+ field.value?.kind === 'bbcd' ||
333
+ field.value?.kind === 'lbcd'
249
334
  ) {
250
335
  return field.value.length;
251
336
  }
252
337
 
338
+ if (field.value?.kind === 'tone') {
339
+ return 2;
340
+ }
341
+
253
342
  return 1;
254
343
  }
255
344
 
@@ -377,10 +466,8 @@ function decodeStruct(
377
466
  }
378
467
 
379
468
  if (field.type === 'u16') {
380
- const raw = bytes[0] | (bytes[1] << 8);
381
-
382
469
  if (!field.reserved) {
383
- result[field.id] = decodeRawValue(field.value ?? { kind: 'integer' }, raw);
470
+ result[field.id] = decodeRawValue(field.value ?? { kind: 'integer' }, bytes);
384
471
  }
385
472
  } else if (!field.reserved) {
386
473
  result[field.id] = decodeRawValue(field.value, length === 1 ? bytes[0] : bytes);
@@ -439,8 +526,15 @@ function encodeStruct(
439
526
  const settingValue = values[field.id];
440
527
 
441
528
  if (field.type === 'u16') {
442
- const encoded = encodeRawValue(field.value ?? { kind: 'integer' }, settingValue ?? 0, 1);
443
- const value = encoded[0] & 0xffff;
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
+
444
538
  writeByte(contents, bufferOffsetFor(radioAddress, memoryConfig, contents), value & 0xff);
445
539
  writeByte(contents, bufferOffsetFor(radioAddress + 1, memoryConfig, contents), (value >> 8) & 0xff);
446
540
  } else {
@@ -459,6 +553,35 @@ function encodeStruct(
459
553
  }
460
554
  }
461
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
+
462
585
  /**
463
586
  * Decode radio-wide settings from a memory image using a JSON memory map.
464
587
  */
@@ -476,7 +599,11 @@ export function decodeMemoryMap(
476
599
  const items: RadioSettingValue[] = [];
477
600
 
478
601
  for (let index = 0; index < count; index += 1) {
479
- items.push(decodeStruct(struct, contents, memoryConfig, index));
602
+ if (isStructInstanceEmpty(struct, contents, memoryConfig, index)) {
603
+ items.push(null);
604
+ } else {
605
+ items.push(decodeStruct(struct, contents, memoryConfig, index));
606
+ }
480
607
  }
481
608
 
482
609
  settings[struct.id] = items;
@@ -507,6 +634,15 @@ export function encodeMemoryMap(
507
634
 
508
635
  for (let index = 0; index < count; index += 1) {
509
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
+
510
646
  const record =
511
647
  item && typeof item === 'object' && !Array.isArray(item) ? (item as Record<string, RadioSettingValue>) : {};
512
648
  encodeStruct(struct, record, contents, memoryConfig, index);
@@ -1,4 +1,9 @@
1
- import type { RadioMemoryMap, RadioMemoryMapFieldUi, RadioMemoryMapValueKind } from '@springfield/ham-radio-api';
1
+ import type {
2
+ RadioMemoryMap,
3
+ RadioMemoryMapFieldUi,
4
+ RadioMemoryMapValueKind,
5
+ RadioSettingValue,
6
+ } from '@springfield/ham-radio-api';
2
7
 
3
8
  /**
4
9
  * Flattened field descriptor for schema-driven settings UI.
@@ -13,13 +18,50 @@ export interface RadioMemoryMapUiField {
13
18
  value?: RadioMemoryMapValueKind;
14
19
  }
15
20
 
21
+ function channelBoundFieldIds(memoryMap: RadioMemoryMap): Set<string> {
22
+ const bindings = memoryMap.channelBindings;
23
+ const ids = new Set<string>();
24
+
25
+ if (!bindings) {
26
+ return ids;
27
+ }
28
+
29
+ for (const key of [
30
+ bindings.receiveFrequency,
31
+ bindings.transmitFrequency,
32
+ bindings.receiveTone,
33
+ bindings.transmitTone,
34
+ bindings.nameField,
35
+ ]) {
36
+ if (key) {
37
+ ids.add(key);
38
+ }
39
+ }
40
+
41
+ return ids;
42
+ }
43
+
16
44
  /**
17
45
  * Collect writable UI fields from a memory map, in declaration order.
46
+ * Channel-bound structs are skipped (use {@link collectChannelMemoryMapUiFields}).
18
47
  */
19
48
  export function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemoryMapUiField[] {
20
49
  const fields: RadioMemoryMapUiField[] = [];
50
+ const channelStructIds = new Set<string>();
51
+
52
+ if (memoryMap.channelBindings) {
53
+ channelStructIds.add(memoryMap.channelBindings.records);
54
+
55
+ if (memoryMap.channelBindings.names) {
56
+ channelStructIds.add(memoryMap.channelBindings.names);
57
+ }
58
+ }
21
59
 
22
60
  for (const struct of memoryMap.structs) {
61
+ if (channelStructIds.has(struct.id)) {
62
+ continue;
63
+ }
64
+
23
65
  const count = struct.count ?? 1;
24
66
 
25
67
  for (let index = 0; index < count; index += 1) {
@@ -51,6 +93,85 @@ export function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemory
51
93
  return fields;
52
94
  }
53
95
 
96
+ /**
97
+ * Collect per-channel UI fields from the `channelBindings.records` struct.
98
+ * Skips reserved fields, fields without `ui`, and fields bound onto RadioChannel.
99
+ */
100
+ export function collectChannelMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemoryMapUiField[] {
101
+ const bindings = memoryMap.channelBindings;
102
+
103
+ if (!bindings) {
104
+ return [];
105
+ }
106
+
107
+ const struct = memoryMap.structs.find((entry) => entry.id === bindings.records);
108
+
109
+ if (!struct) {
110
+ return [];
111
+ }
112
+
113
+ const boundIds = channelBoundFieldIds(memoryMap);
114
+ const fields: RadioMemoryMapUiField[] = [];
115
+
116
+ for (const field of struct.fields) {
117
+ if (field.reserved || !field.ui || boundIds.has(field.id)) {
118
+ continue;
119
+ }
120
+
121
+ fields.push({
122
+ path: field.id,
123
+ structId: struct.id,
124
+ fieldId: field.id,
125
+ ui: field.ui,
126
+ value: field.value,
127
+ });
128
+ }
129
+
130
+ return fields;
131
+ }
132
+
133
+ /**
134
+ * Format a decoded memory-map field value for read-only display (e.g. channel table).
135
+ */
136
+ export function formatMemoryMapFieldValue(
137
+ value: RadioSettingValue | undefined,
138
+ field: Pick<RadioMemoryMapUiField, 'value' | 'fieldId'>,
139
+ ): string {
140
+ if (value === undefined || value === null) {
141
+ return '';
142
+ }
143
+
144
+ if (field.value?.kind === 'enum' && typeof value === 'string') {
145
+ return value;
146
+ }
147
+
148
+ if (field.value?.kind === 'boolean' || typeof value === 'boolean') {
149
+ if (field.fieldId === 'wide') {
150
+ return value ? 'Wide' : 'Narrow';
151
+ }
152
+
153
+ if (field.fieldId === 'scan') {
154
+ return value ? 'On' : 'Off';
155
+ }
156
+
157
+ return value ? 'On' : 'Off';
158
+ }
159
+
160
+ if (field.fieldId === 'lowpower' && typeof value === 'number') {
161
+ return value === 0 ? 'High' : 'Low';
162
+ }
163
+
164
+ if (field.fieldId === 'scode' && typeof value === 'number') {
165
+ return String(value + 1);
166
+ }
167
+
168
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
169
+ return String(value);
170
+ }
171
+
172
+ return '';
173
+ }
174
+
54
175
  /**
55
176
  * Group UI fields by their ui.group key, preserving first-seen group order.
56
177
  */
@@ -18,6 +18,9 @@
18
18
  "items": {
19
19
  "$ref": "#/definitions/struct"
20
20
  }
21
+ },
22
+ "channelBindings": {
23
+ "$ref": "#/definitions/channelBindings"
21
24
  }
22
25
  },
23
26
  "definitions": {
@@ -27,6 +30,28 @@
27
30
  { "type": "string", "pattern": "^(0[xX][0-9a-fA-F]+|[0-9]+)$" }
28
31
  ]
29
32
  },
33
+ "channelBindings": {
34
+ "type": "object",
35
+ "required": ["records", "receiveFrequency", "transmitFrequency", "receiveTone", "transmitTone"],
36
+ "properties": {
37
+ "records": { "type": "string", "minLength": 1 },
38
+ "names": { "type": "string", "minLength": 1 },
39
+ "nameField": { "type": "string", "minLength": 1 },
40
+ "receiveFrequency": { "type": "string", "minLength": 1 },
41
+ "transmitFrequency": { "type": "string", "minLength": 1 },
42
+ "receiveTone": { "type": "string", "minLength": 1 },
43
+ "transmitTone": { "type": "string", "minLength": 1 }
44
+ },
45
+ "additionalProperties": false
46
+ },
47
+ "emptyWhen": {
48
+ "type": "object",
49
+ "required": ["equals"],
50
+ "properties": {
51
+ "equals": { "type": "integer", "minimum": 0, "maximum": 255 }
52
+ },
53
+ "additionalProperties": false
54
+ },
30
55
  "ui": {
31
56
  "type": "object",
32
57
  "required": ["group", "label", "widget"],
@@ -112,6 +137,31 @@
112
137
  "length": { "type": "integer", "minimum": 1 }
113
138
  },
114
139
  "additionalProperties": false
140
+ },
141
+ {
142
+ "type": "object",
143
+ "required": ["kind", "length"],
144
+ "properties": {
145
+ "kind": { "const": "lbcd" },
146
+ "length": { "type": "integer", "minimum": 1 },
147
+ "scale": { "type": "number" }
148
+ },
149
+ "additionalProperties": false
150
+ },
151
+ {
152
+ "type": "object",
153
+ "required": ["kind", "values"],
154
+ "properties": {
155
+ "kind": { "const": "tone" },
156
+ "values": {
157
+ "type": "array",
158
+ "items": { "type": "integer" },
159
+ "minItems": 1
160
+ },
161
+ "ctcssMin": { "type": "integer" },
162
+ "reverseOffset": { "type": "integer" }
163
+ },
164
+ "additionalProperties": false
115
165
  }
116
166
  ]
117
167
  },
@@ -143,7 +193,9 @@
143
193
  "items": { "$ref": "#/definitions/field" }
144
194
  },
145
195
  "count": { "type": "integer", "minimum": 1 },
146
- "stride": { "type": "integer", "minimum": 1 }
196
+ "stride": { "type": "integer", "minimum": 1 },
197
+ "emptyWhen": { "$ref": "#/definitions/emptyWhen" },
198
+ "clearEmpty": { "type": "boolean" }
147
199
  },
148
200
  "additionalProperties": false
149
201
  }