@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memory/memory-map-channels.d.ts.map +1 -1
- package/dist/memory/memory-map-channels.js +163 -8
- package/dist/memory/memory-map-channels.js.map +1 -1
- package/dist/memory/memory-map-codec.d.ts +6 -1
- package/dist/memory/memory-map-codec.d.ts.map +1 -1
- package/dist/memory/memory-map-codec.js +73 -19
- package/dist/memory/memory-map-codec.js.map +1 -1
- package/dist/memory/memory-map-radio-codec.d.ts +34 -0
- package/dist/memory/memory-map-radio-codec.d.ts.map +1 -0
- package/dist/memory/memory-map-radio-codec.js +72 -0
- package/dist/memory/memory-map-radio-codec.js.map +1 -0
- package/dist/memory/memory-map-ui.d.ts.map +1 -1
- package/dist/memory/memory-map-ui.js +18 -9
- package/dist/memory/memory-map-ui.js.map +1 -1
- package/dist/schemas/radio-memory-map-schema.json +33 -3
- package/dist/schemas/radio-protocol-schema.json +15 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/memory/memory-map-channels.ts +199 -8
- package/src/memory/memory-map-codec.ts +85 -19
- package/src/memory/memory-map-radio-codec.ts +96 -0
- package/src/memory/memory-map-ui.ts +20 -9
- package/src/schemas/radio-memory-map-schema.json +33 -3
- package/src/schemas/radio-protocol-schema.json +15 -0
|
@@ -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
|
|
88
|
-
const
|
|
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 =
|
|
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:
|
|
121
|
-
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]:
|
|
188
|
-
[bindings.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
|
-
|
|
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
|
|
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
|
-
|
|
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) ??
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
533
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
117
|
-
if (
|
|
121
|
+
for (const source of [struct, extrasStruct]) {
|
|
122
|
+
if (!source) {
|
|
118
123
|
continue;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
fields
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|