@springfield/ham-radio-utils 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +23 -0
- package/dist/memory/memory-map-channels.d.ts.map +1 -0
- package/dist/memory/memory-map-channels.js +188 -0
- package/dist/memory/memory-map-channels.js.map +1 -0
- package/dist/memory/memory-map-codec.d.ts.map +1 -1
- package/dist/memory/memory-map-codec.js +111 -6
- package/dist/memory/memory-map-codec.js.map +1 -1
- package/dist/memory/memory-map-ui.d.ts.map +1 -1
- package/dist/memory/memory-map-ui.js +10 -0
- package/dist/memory/memory-map-ui.js.map +1 -1
- package/dist/schemas/radio-memory-map-schema.json +53 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/memory/memory-map-channels.ts +264 -0
- package/src/memory/memory-map-codec.ts +143 -7
- package/src/memory/memory-map-ui.ts +13 -0
- package/src/schemas/radio-memory-map-schema.json +53 -1
|
@@ -3,7 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function collectMemoryMapUiFields(memoryMap) {
|
|
5
5
|
const fields = [];
|
|
6
|
+
const channelStructIds = new Set();
|
|
7
|
+
if (memoryMap.channelBindings) {
|
|
8
|
+
channelStructIds.add(memoryMap.channelBindings.records);
|
|
9
|
+
if (memoryMap.channelBindings.names) {
|
|
10
|
+
channelStructIds.add(memoryMap.channelBindings.names);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
for (const struct of memoryMap.structs) {
|
|
14
|
+
if (channelStructIds.has(struct.id)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
7
17
|
const count = struct.count ?? 1;
|
|
8
18
|
for (let index = 0; index < count; index += 1) {
|
|
9
19
|
for (const field of struct.fields) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory-map-ui.js","sourceRoot":"","sources":["../../src/memory/memory-map-ui.ts"],"names":[],"mappings":"AAeA;;GAEG;AACH,MAAM,UAAU,wBAAwB,CAAC,SAAyB;IAChE,MAAM,MAAM,GAA4B,EAAE,CAAC;IAE3C,KAAK,MAAM,MAAM,IAAI,SAAS,CAAC,OAAO,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC;QAEhC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC9C,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;gBAClC,IAAI,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;oBAChC,SAAS;gBACX,CAAC;gBAED,MAAM,IAAI,GACR,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,IAAI,KAAK,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC;gBAE/E,MAAM,KAAK,GACT,KAAK,GAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;oBAC7C,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE;oBAClC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC;gBAErB,MAAM,CAAC,IAAI,CAAC;oBACV,IAAI;oBACJ,QAAQ,EAAE,MAAM,CAAC,EAAE;oBACnB,OAAO,EAAE,KAAK,CAAC,EAAE;oBACjB,UAAU,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;oBACzC,EAAE,EAAE,EAAE,GAAG,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE;oBAC1B,KAAK,EAAE,KAAK,CAAC,KAAK;iBACnB,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAA+B;IACpE,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmC,CAAC;IAE1D,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC;QAE5C,IAAI,QAAQ,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvB,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC","sourcesContent":["import type { RadioMemoryMap, RadioMemoryMapFieldUi, RadioMemoryMapValueKind } from '@springfield/ham-radio-api';\n\n/**\n * Flattened field descriptor for schema-driven settings UI.\n */\nexport interface RadioMemoryMapUiField {\n /** Dot path into decoded settings, e.g. settings.squelch or pttid.0.code */\n path: string;\n structId: string;\n fieldId: string;\n arrayIndex?: number;\n ui: RadioMemoryMapFieldUi;\n value?: RadioMemoryMapValueKind;\n}\n\n/**\n * Collect writable UI fields from a memory map, in declaration order.\n */\nexport function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemoryMapUiField[] {\n const fields: RadioMemoryMapUiField[] = [];\n\n for (const struct of memoryMap.structs) {\n const count = struct.count ?? 1;\n\n for (let index = 0; index < count; index += 1) {\n for (const field of struct.fields) {\n if (field.reserved || !field.ui) {\n continue;\n }\n\n const path =\n count > 1 ? `${struct.id}.${index}.${field.id}` : `${struct.id}.${field.id}`;\n\n const label =\n count > 1 && field.ui.label.indexOf('%') === -1\n ? `${field.ui.label} ${index + 1}`\n : field.ui.label;\n\n fields.push({\n path,\n structId: struct.id,\n fieldId: field.id,\n arrayIndex: count > 1 ? index : undefined,\n ui: { ...field.ui, label },\n value: field.value,\n });\n }\n }\n }\n\n return fields;\n}\n\n/**\n * Group UI fields by their ui.group key, preserving first-seen group order.\n */\nexport function groupMemoryMapUiFields(fields: RadioMemoryMapUiField[]): Map<string, RadioMemoryMapUiField[]> {\n const groups = new Map<string, RadioMemoryMapUiField[]>();\n\n for (const field of fields) {\n const existing = groups.get(field.ui.group);\n\n if (existing) {\n existing.push(field);\n } else {\n groups.set(field.ui.group, [field]);\n }\n }\n\n return groups;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"memory-map-ui.js","sourceRoot":"","sources":["../../src/memory/memory-map-ui.ts"],"names":[],"mappings":"AAeA;;GAEG;AACH,MAAM,UAAU,wBAAwB,CAAC,SAAyB;IAChE,MAAM,MAAM,GAA4B,EAAE,CAAC;IAC3C,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAU,CAAC;IAE3C,IAAI,SAAS,CAAC,eAAe,EAAE,CAAC;QAC9B,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;QAExD,IAAI,SAAS,CAAC,eAAe,CAAC,KAAK,EAAE,CAAC;YACpC,gBAAgB,CAAC,GAAG,CAAC,SAAS,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QACxD,CAAC;IACH,CAAC;IAED,KAAK,MAAM,MAAM,IAAI,SAAS,CAAC,OAAO,EAAE,CAAC;QACvC,IAAI,gBAAgB,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC;YACpC,SAAS;QACX,CAAC;QAED,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC,CAAC;QAEhC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC9C,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;gBAClC,IAAI,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;oBAChC,SAAS;gBACX,CAAC;gBAED,MAAM,IAAI,GACR,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,IAAI,KAAK,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,IAAI,KAAK,CAAC,EAAE,EAAE,CAAC;gBAE/E,MAAM,KAAK,GACT,KAAK,GAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;oBAC7C,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE;oBAClC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC;gBAErB,MAAM,CAAC,IAAI,CAAC;oBACV,IAAI;oBACJ,QAAQ,EAAE,MAAM,CAAC,EAAE;oBACnB,OAAO,EAAE,KAAK,CAAC,EAAE;oBACjB,UAAU,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS;oBACzC,EAAE,EAAE,EAAE,GAAG,KAAK,CAAC,EAAE,EAAE,KAAK,EAAE;oBAC1B,KAAK,EAAE,KAAK,CAAC,KAAK;iBACnB,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAA+B;IACpE,MAAM,MAAM,GAAG,IAAI,GAAG,EAAmC,CAAC;IAE1D,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC;QAE5C,IAAI,QAAQ,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvB,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC","sourcesContent":["import type { RadioMemoryMap, RadioMemoryMapFieldUi, RadioMemoryMapValueKind } from '@springfield/ham-radio-api';\n\n/**\n * Flattened field descriptor for schema-driven settings UI.\n */\nexport interface RadioMemoryMapUiField {\n /** Dot path into decoded settings, e.g. settings.squelch or pttid.0.code */\n path: string;\n structId: string;\n fieldId: string;\n arrayIndex?: number;\n ui: RadioMemoryMapFieldUi;\n value?: RadioMemoryMapValueKind;\n}\n\n/**\n * Collect writable UI fields from a memory map, in declaration order.\n */\nexport function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemoryMapUiField[] {\n const fields: RadioMemoryMapUiField[] = [];\n const channelStructIds = new Set<string>();\n\n if (memoryMap.channelBindings) {\n channelStructIds.add(memoryMap.channelBindings.records);\n\n if (memoryMap.channelBindings.names) {\n channelStructIds.add(memoryMap.channelBindings.names);\n }\n }\n\n for (const struct of memoryMap.structs) {\n if (channelStructIds.has(struct.id)) {\n continue;\n }\n\n const count = struct.count ?? 1;\n\n for (let index = 0; index < count; index += 1) {\n for (const field of struct.fields) {\n if (field.reserved || !field.ui) {\n continue;\n }\n\n const path =\n count > 1 ? `${struct.id}.${index}.${field.id}` : `${struct.id}.${field.id}`;\n\n const label =\n count > 1 && field.ui.label.indexOf('%') === -1\n ? `${field.ui.label} ${index + 1}`\n : field.ui.label;\n\n fields.push({\n path,\n structId: struct.id,\n fieldId: field.id,\n arrayIndex: count > 1 ? index : undefined,\n ui: { ...field.ui, label },\n value: field.value,\n });\n }\n }\n }\n\n return fields;\n}\n\n/**\n * Group UI fields by their ui.group key, preserving first-seen group order.\n */\nexport function groupMemoryMapUiFields(fields: RadioMemoryMapUiField[]): Map<string, RadioMemoryMapUiField[]> {\n const groups = new Map<string, RadioMemoryMapUiField[]>();\n\n for (const field of fields) {\n const existing = groups.get(field.ui.group);\n\n if (existing) {\n existing.push(field);\n } else {\n groups.set(field.ui.group, [field]);\n }\n }\n\n return groups;\n}\n"]}
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springfield/ham-radio-utils",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"author": "Bryan Hunt",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test:unit": "tsx --test test/unit/**/*.test.ts"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@springfield/ham-radio-api": "^17.
|
|
33
|
+
"@springfield/ham-radio-api": "^17.2.0",
|
|
34
34
|
"ajv": "^8.20.0",
|
|
35
35
|
"ajv-formats": "^3.0.1",
|
|
36
36
|
"fishery": "^2.4.0",
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Frequency,
|
|
3
|
+
type RadioChannel,
|
|
4
|
+
type RadioMemoryConfig,
|
|
5
|
+
type RadioMemoryMap,
|
|
6
|
+
type RadioMemoryMapToneValue,
|
|
7
|
+
type RadioProgram,
|
|
8
|
+
type RadioProgrammedChannel,
|
|
9
|
+
type RadioSettings,
|
|
10
|
+
type RadioSettingValue,
|
|
11
|
+
type RadioTone,
|
|
12
|
+
RadioToneType,
|
|
13
|
+
} from '@springfield/ham-radio-api';
|
|
14
|
+
import { decodeMemoryMap, encodeMemoryMap } from './memory-map-codec.js';
|
|
15
|
+
|
|
16
|
+
function isToneValue(value: RadioSettingValue | undefined): value is RadioMemoryMapToneValue {
|
|
17
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && 'mode' in value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toneToRadioTone(value: RadioSettingValue | undefined): RadioTone {
|
|
21
|
+
if (!isToneValue(value) || value.mode === 'none') {
|
|
22
|
+
return { tone: 0, type: RadioToneType.CTCSS };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (value.mode === 'ctcss') {
|
|
26
|
+
return { tone: value.value, type: RadioToneType.CTCSS };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { tone: value.code, type: RadioToneType.DCS };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function radioToneToMapTone(tone: RadioTone | undefined): RadioMemoryMapToneValue {
|
|
33
|
+
if (!tone || !tone.tone) {
|
|
34
|
+
return { mode: 'none' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (tone.type === RadioToneType.DCS) {
|
|
38
|
+
return { mode: 'dcs', code: Number(tone.tone), polarity: 'N' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { mode: 'ctcss', value: Number(tone.tone) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asRecord(value: RadioSettingValue | undefined): Record<string, RadioSettingValue> | undefined {
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value as Record<string, RadioSettingValue>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Project decoded memory-map channel structs into RadioProgrammedChannel[].
|
|
54
|
+
*/
|
|
55
|
+
export function bindingsToChannels(
|
|
56
|
+
memoryMap: RadioMemoryMap,
|
|
57
|
+
settings: RadioSettings,
|
|
58
|
+
): RadioProgrammedChannel[] {
|
|
59
|
+
const bindings = memoryMap.channelBindings;
|
|
60
|
+
|
|
61
|
+
if (!bindings) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const records = settings[bindings.records];
|
|
66
|
+
const names = bindings.names ? settings[bindings.names] : undefined;
|
|
67
|
+
const recordItems = Array.isArray(records) ? records : [];
|
|
68
|
+
const nameItems = Array.isArray(names) ? names : [];
|
|
69
|
+
const nameField = bindings.nameField ?? 'name';
|
|
70
|
+
const boundFieldIds = new Set([
|
|
71
|
+
bindings.receiveFrequency,
|
|
72
|
+
bindings.transmitFrequency,
|
|
73
|
+
bindings.receiveTone,
|
|
74
|
+
bindings.transmitTone,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const channels: RadioProgrammedChannel[] = [];
|
|
78
|
+
|
|
79
|
+
for (let index = 0; index < recordItems.length; index += 1) {
|
|
80
|
+
const record = asRecord(recordItems[index]);
|
|
81
|
+
|
|
82
|
+
if (!record) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const nameRecord = asRecord(nameItems[index]);
|
|
87
|
+
const nameValue = nameRecord?.[nameField];
|
|
88
|
+
const name = typeof nameValue === 'string' ? nameValue : '';
|
|
89
|
+
|
|
90
|
+
const receiveFrequency = Number(record[bindings.receiveFrequency] ?? 0);
|
|
91
|
+
const transmitFrequency = Number(record[bindings.transmitFrequency] ?? 0);
|
|
92
|
+
|
|
93
|
+
const channelSettings: RadioSettings = {};
|
|
94
|
+
|
|
95
|
+
for (const [fieldId, fieldValue] of Object.entries(record)) {
|
|
96
|
+
if (boundFieldIds.has(fieldId) || fieldId.startsWith('_')) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
channelSettings[fieldId] = fieldValue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Map Chirp lowpower index onto legacy transmitPower watts for UV-5R UI compatibility.
|
|
104
|
+
if (typeof channelSettings.lowpower === 'number') {
|
|
105
|
+
channelSettings.transmitPower = channelSettings.lowpower === 0 ? 5 : 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (typeof channelSettings.wide === 'boolean') {
|
|
109
|
+
channelSettings.mode = channelSettings.wide ? 'FM' : 'NFM';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof channelSettings.scan === 'boolean') {
|
|
113
|
+
channelSettings.skip = channelSettings.scan ? '' : 'S';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const radioChannel: RadioChannel = {
|
|
117
|
+
name,
|
|
118
|
+
receiveFrequency: Frequency(receiveFrequency),
|
|
119
|
+
transmitFrequency: Frequency(transmitFrequency),
|
|
120
|
+
receiveTone: toneToRadioTone(record[bindings.receiveTone]),
|
|
121
|
+
transmitTone: toneToRadioTone(record[bindings.transmitTone]),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
channels.push({
|
|
125
|
+
channelNumber: index,
|
|
126
|
+
radioChannel,
|
|
127
|
+
settings: Object.keys(channelSettings).length > 0 ? channelSettings : undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return channels;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Strip channel-bound structs from decoded settings so RadioProgram.settings
|
|
136
|
+
* only contains radio-wide settings.
|
|
137
|
+
*/
|
|
138
|
+
export function settingsWithoutChannels(memoryMap: RadioMemoryMap, settings: RadioSettings): RadioSettings {
|
|
139
|
+
const bindings = memoryMap.channelBindings;
|
|
140
|
+
|
|
141
|
+
if (!bindings) {
|
|
142
|
+
return settings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const next: RadioSettings = { ...settings };
|
|
146
|
+
delete next[bindings.records];
|
|
147
|
+
|
|
148
|
+
if (bindings.names) {
|
|
149
|
+
delete next[bindings.names];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Merge RadioProgram channels back into a memory-map settings bag for encode.
|
|
157
|
+
*/
|
|
158
|
+
export function programToChannelSettings(memoryMap: RadioMemoryMap, program: RadioProgram): RadioSettings {
|
|
159
|
+
const bindings = memoryMap.channelBindings;
|
|
160
|
+
|
|
161
|
+
if (!bindings) {
|
|
162
|
+
return program.settings;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const recordsStruct = memoryMap.structs.find((struct) => struct.id === bindings.records);
|
|
166
|
+
const namesStruct = bindings.names ? memoryMap.structs.find((struct) => struct.id === bindings.names) : undefined;
|
|
167
|
+
const count = recordsStruct?.count ?? 0;
|
|
168
|
+
const nameField = bindings.nameField ?? 'name';
|
|
169
|
+
|
|
170
|
+
const records: RadioSettingValue[] = Array.from({ length: count }, () => null);
|
|
171
|
+
const names: RadioSettingValue[] = Array.from({ length: count }, () => null);
|
|
172
|
+
|
|
173
|
+
for (const programmed of program.channels) {
|
|
174
|
+
const index = programmed.channelNumber;
|
|
175
|
+
|
|
176
|
+
if (index < 0 || index >= count) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (typeof programmed.radioChannel === 'string') {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const channel = programmed.radioChannel;
|
|
185
|
+
const channelSettings = programmed.settings ?? {};
|
|
186
|
+
const record: Record<string, RadioSettingValue> = {
|
|
187
|
+
[bindings.receiveFrequency]: channel.receiveFrequency,
|
|
188
|
+
[bindings.transmitFrequency]: channel.transmitFrequency,
|
|
189
|
+
[bindings.receiveTone]: radioToneToMapTone(channel.receiveTone),
|
|
190
|
+
[bindings.transmitTone]: radioToneToMapTone(channel.transmitTone),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
for (const [key, value] of Object.entries(channelSettings)) {
|
|
194
|
+
if (key === 'transmitPower') {
|
|
195
|
+
record.lowpower = value === 5 || value === 4 || value === 0 ? 0 : 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (key === 'mode') {
|
|
200
|
+
record.wide = value === 'FM';
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (key === 'skip') {
|
|
205
|
+
record.scan = value !== 'S';
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
record[key] = value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (record.lowpower === undefined) {
|
|
213
|
+
record.lowpower = 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (record.wide === undefined) {
|
|
217
|
+
record.wide = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (record.scan === undefined) {
|
|
221
|
+
record.scan = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
records[index] = record;
|
|
225
|
+
names[index] = { [nameField]: channel.name ?? '' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const bag: RadioSettings = { ...program.settings, [bindings.records]: records };
|
|
229
|
+
|
|
230
|
+
if (bindings.names && namesStruct) {
|
|
231
|
+
bag[bindings.names] = names;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return bag;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Decode a full RadioProgram (channels + radio-wide settings) from memory.
|
|
239
|
+
*/
|
|
240
|
+
export function decodeRadioProgram(
|
|
241
|
+
memoryMap: RadioMemoryMap,
|
|
242
|
+
contents: Uint8Array,
|
|
243
|
+
memoryConfig: RadioMemoryConfig,
|
|
244
|
+
): RadioProgram {
|
|
245
|
+
const decoded = decodeMemoryMap(memoryMap, contents, memoryConfig);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
channels: bindingsToChannels(memoryMap, decoded),
|
|
249
|
+
settings: settingsWithoutChannels(memoryMap, decoded),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Encode a RadioProgram into an existing memory image (patch in place).
|
|
255
|
+
*/
|
|
256
|
+
export function encodeRadioProgram(
|
|
257
|
+
memoryMap: RadioMemoryMap,
|
|
258
|
+
program: RadioProgram,
|
|
259
|
+
contents: Uint8Array,
|
|
260
|
+
memoryConfig: RadioMemoryConfig,
|
|
261
|
+
): Uint8Array {
|
|
262
|
+
const bag = programToChannelSettings(memoryMap, program);
|
|
263
|
+
return encodeMemoryMap(memoryMap, bag, contents, memoryConfig);
|
|
264
|
+
}
|
|
@@ -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' },
|
|
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,
|
|
443
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -18,8 +18,21 @@ export interface RadioMemoryMapUiField {
|
|
|
18
18
|
*/
|
|
19
19
|
export function collectMemoryMapUiFields(memoryMap: RadioMemoryMap): RadioMemoryMapUiField[] {
|
|
20
20
|
const fields: RadioMemoryMapUiField[] = [];
|
|
21
|
+
const channelStructIds = new Set<string>();
|
|
22
|
+
|
|
23
|
+
if (memoryMap.channelBindings) {
|
|
24
|
+
channelStructIds.add(memoryMap.channelBindings.records);
|
|
25
|
+
|
|
26
|
+
if (memoryMap.channelBindings.names) {
|
|
27
|
+
channelStructIds.add(memoryMap.channelBindings.names);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
for (const struct of memoryMap.structs) {
|
|
32
|
+
if (channelStructIds.has(struct.id)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
const count = struct.count ?? 1;
|
|
24
37
|
|
|
25
38
|
for (let index = 0; index < count; index += 1) {
|