@springfield/ham-radio-utils 4.1.2 → 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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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 +22 -0
- package/dist/memory/memory-map-codec.d.ts.map +1 -0
- package/dist/memory/memory-map-codec.js +502 -0
- package/dist/memory/memory-map-codec.js.map +1 -0
- package/dist/memory/memory-map-ui.d.ts +22 -0
- package/dist/memory/memory-map-ui.d.ts.map +1 -0
- package/dist/memory/memory-map-ui.js +56 -0
- package/dist/memory/memory-map-ui.js.map +1 -0
- package/dist/schemas/radio-memory-map-schema.json +203 -0
- package/dist/utils/schema-validator.d.ts +2 -0
- package/dist/utils/schema-validator.d.ts.map +1 -1
- package/dist/utils/schema-validator.js +9 -2
- package/dist/utils/schema-validator.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +4 -0
- package/src/memory/memory-map-channels.ts +264 -0
- package/src/memory/memory-map-codec.ts +658 -0
- package/src/memory/memory-map-ui.ts +84 -0
- package/src/schemas/radio-memory-map-schema.json +203 -0
- package/src/utils/schema-validator.ts +11 -2
|
@@ -0,0 +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;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"]}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Radio Memory-Map DSL Schema",
|
|
4
|
+
"description": "Schema for defining radio EEPROM settings layout as JSON (Chirp MEM_FORMAT as data)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["structs"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Optional map version"
|
|
11
|
+
},
|
|
12
|
+
"description": {
|
|
13
|
+
"type": "string"
|
|
14
|
+
},
|
|
15
|
+
"structs": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"items": {
|
|
19
|
+
"$ref": "#/definitions/struct"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"channelBindings": {
|
|
23
|
+
"$ref": "#/definitions/channelBindings"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"definitions": {
|
|
27
|
+
"seek": {
|
|
28
|
+
"oneOf": [
|
|
29
|
+
{ "type": "integer", "minimum": 0 },
|
|
30
|
+
{ "type": "string", "pattern": "^(0[xX][0-9a-fA-F]+|[0-9]+)$" }
|
|
31
|
+
]
|
|
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
|
+
},
|
|
55
|
+
"ui": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["group", "label", "widget"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"group": { "type": "string" },
|
|
60
|
+
"label": { "type": "string" },
|
|
61
|
+
"widget": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": ["integer", "select", "switch", "text", "number"]
|
|
64
|
+
},
|
|
65
|
+
"description": { "type": "string" },
|
|
66
|
+
"writable": { "type": "boolean" }
|
|
67
|
+
},
|
|
68
|
+
"additionalProperties": false
|
|
69
|
+
},
|
|
70
|
+
"value": {
|
|
71
|
+
"oneOf": [
|
|
72
|
+
{
|
|
73
|
+
"type": "object",
|
|
74
|
+
"required": ["kind"],
|
|
75
|
+
"properties": {
|
|
76
|
+
"kind": { "const": "integer" },
|
|
77
|
+
"min": { "type": "number" },
|
|
78
|
+
"max": { "type": "number" }
|
|
79
|
+
},
|
|
80
|
+
"additionalProperties": false
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["kind"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"kind": { "const": "boolean" }
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"type": "object",
|
|
92
|
+
"required": ["kind", "values"],
|
|
93
|
+
"properties": {
|
|
94
|
+
"kind": { "const": "enum" },
|
|
95
|
+
"values": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"items": { "type": "string" },
|
|
98
|
+
"minItems": 1
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"additionalProperties": false
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"type": "object",
|
|
105
|
+
"required": ["kind", "length"],
|
|
106
|
+
"properties": {
|
|
107
|
+
"kind": { "const": "ascii" },
|
|
108
|
+
"length": { "type": "integer", "minimum": 1 }
|
|
109
|
+
},
|
|
110
|
+
"additionalProperties": false
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"type": "object",
|
|
114
|
+
"required": ["kind", "length"],
|
|
115
|
+
"properties": {
|
|
116
|
+
"kind": { "const": "digits" },
|
|
117
|
+
"length": { "type": "integer", "minimum": 1 },
|
|
118
|
+
"scale": { "type": "number" }
|
|
119
|
+
},
|
|
120
|
+
"additionalProperties": false
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "object",
|
|
124
|
+
"required": ["kind", "length"],
|
|
125
|
+
"properties": {
|
|
126
|
+
"kind": { "const": "dtmf" },
|
|
127
|
+
"length": { "type": "integer", "minimum": 1 },
|
|
128
|
+
"charset": { "type": "string" }
|
|
129
|
+
},
|
|
130
|
+
"additionalProperties": false
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"type": "object",
|
|
134
|
+
"required": ["kind", "length"],
|
|
135
|
+
"properties": {
|
|
136
|
+
"kind": { "const": "bbcd" },
|
|
137
|
+
"length": { "type": "integer", "minimum": 1 }
|
|
138
|
+
},
|
|
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
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
"field": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"required": ["id", "type"],
|
|
171
|
+
"properties": {
|
|
172
|
+
"id": { "type": "string", "minLength": 1 },
|
|
173
|
+
"type": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"enum": ["u8", "u16", "bits", "char"]
|
|
176
|
+
},
|
|
177
|
+
"width": { "type": "integer", "minimum": 1, "maximum": 8 },
|
|
178
|
+
"reserved": { "type": "boolean" },
|
|
179
|
+
"value": { "$ref": "#/definitions/value" },
|
|
180
|
+
"ui": { "$ref": "#/definitions/ui" }
|
|
181
|
+
},
|
|
182
|
+
"additionalProperties": false
|
|
183
|
+
},
|
|
184
|
+
"struct": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"required": ["id", "seek", "fields"],
|
|
187
|
+
"properties": {
|
|
188
|
+
"id": { "type": "string", "minLength": 1 },
|
|
189
|
+
"seek": { "$ref": "#/definitions/seek" },
|
|
190
|
+
"fields": {
|
|
191
|
+
"type": "array",
|
|
192
|
+
"minItems": 1,
|
|
193
|
+
"items": { "$ref": "#/definitions/field" }
|
|
194
|
+
},
|
|
195
|
+
"count": { "type": "integer", "minimum": 1 },
|
|
196
|
+
"stride": { "type": "integer", "minimum": 1 },
|
|
197
|
+
"emptyWhen": { "$ref": "#/definitions/emptyWhen" },
|
|
198
|
+
"clearEmpty": { "type": "boolean" }
|
|
199
|
+
},
|
|
200
|
+
"additionalProperties": false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -6,5 +6,7 @@ export declare class SchemaValidator {
|
|
|
6
6
|
private ajv;
|
|
7
7
|
constructor();
|
|
8
8
|
validateRadioProtocol(config: unknown): ValidationResult;
|
|
9
|
+
validateMemoryMap(memoryMap: unknown): ValidationResult;
|
|
10
|
+
private validateAgainst;
|
|
9
11
|
}
|
|
10
12
|
//# sourceMappingURL=schema-validator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-validator.d.ts","sourceRoot":"","sources":["../../src/utils/schema-validator.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"schema-validator.d.ts","sourceRoot":"","sources":["../../src/utils/schema-validator.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAOD,qBAAa,eAAe;IAC1B,OAAO,CAAC,GAAG,CAAM;IAEjB,cAMC;IAED,qBAAqB,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,CAEvD;IAED,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,gBAAgB,CAEtD;IAED,OAAO,CAAC,eAAe;CAqBxB"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Ajv } from 'ajv';
|
|
2
2
|
import addFormats from 'ajv-formats';
|
|
3
3
|
import radioProtocolSchema from '../schemas/radio-protocol-schema.json' with { type: 'json' };
|
|
4
|
+
import radioMemoryMapSchema from '../schemas/radio-memory-map-schema.json' with { type: 'json' };
|
|
4
5
|
export class SchemaValidator {
|
|
5
6
|
ajv;
|
|
6
7
|
constructor() {
|
|
@@ -11,8 +12,14 @@ export class SchemaValidator {
|
|
|
11
12
|
addFormats(this.ajv);
|
|
12
13
|
}
|
|
13
14
|
validateRadioProtocol(config) {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
return this.validateAgainst(radioProtocolSchema, config);
|
|
16
|
+
}
|
|
17
|
+
validateMemoryMap(memoryMap) {
|
|
18
|
+
return this.validateAgainst(radioMemoryMapSchema, memoryMap);
|
|
19
|
+
}
|
|
20
|
+
validateAgainst(schema, data) {
|
|
21
|
+
const validate = this.ajv.compile(schema);
|
|
22
|
+
const valid = validate(data);
|
|
16
23
|
if (valid) {
|
|
17
24
|
return { valid: true };
|
|
18
25
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-validator.js","sourceRoot":"","sources":["../../src/utils/schema-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAC1B,OAAO,UAAU,MAAM,aAAa,CAAC;AACrC,OAAO,mBAAmB,MAAM,uCAAuC,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"schema-validator.js","sourceRoot":"","sources":["../../src/utils/schema-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAC1B,OAAO,UAAU,MAAM,aAAa,CAAC;AACrC,OAAO,mBAAmB,MAAM,uCAAuC,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAC9F,OAAO,oBAAoB,MAAM,yCAAyC,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAYjG,MAAM,OAAO,eAAe;IAClB,GAAG,CAAM;IAEjB;QACE,IAAI,CAAC,GAAG,GAAG,IAAI,GAAG,CAAC;YACjB,SAAS,EAAE,IAAI;YACf,OAAO,EAAE,IAAI;SACd,CAAC,CAAC;QACF,UAA2C,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACzD,CAAC;IAED,qBAAqB,CAAC,MAAe;QACnC,OAAO,IAAI,CAAC,eAAe,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;IAC3D,CAAC;IAED,iBAAiB,CAAC,SAAkB;QAClC,OAAO,IAAI,CAAC,eAAe,CAAC,oBAAoB,EAAE,SAAS,CAAC,CAAC;IAC/D,CAAC;IAEO,eAAe,CAAC,MAAc,EAAE,IAAa;QACnD,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC1C,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;QAE7B,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;QACzB,CAAC;QAED,IAAI,MAAM,GAAa,EAAE,CAAC;QAC1B,IAAI,QAAQ,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClD,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAe,EAAE,EAAE;gBAC/C,MAAM,IAAI,GAAG,KAAK,CAAC,YAAY,IAAI,MAAM,CAAC;gBAC1C,OAAO,GAAG,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;YACrC,CAAC,CAAC,CAAC;QACL,CAAC;QAED,OAAO;YACL,MAAM;YACN,KAAK,EAAE,KAAK;SACb,CAAC;IACJ,CAAC;CACF","sourcesContent":["import { Ajv } from 'ajv';\nimport addFormats from 'ajv-formats';\nimport radioProtocolSchema from '../schemas/radio-protocol-schema.json' with { type: 'json' };\nimport radioMemoryMapSchema from '../schemas/radio-memory-map-schema.json' with { type: 'json' };\n\nexport interface ValidationResult {\n valid: boolean;\n errors?: string[];\n}\n\ninterface AjvError {\n instancePath?: string;\n message?: string;\n}\n\nexport class SchemaValidator {\n private ajv: Ajv;\n\n constructor() {\n this.ajv = new Ajv({\n allErrors: true,\n verbose: true,\n });\n (addFormats as unknown as (ajv: Ajv) => Ajv)(this.ajv);\n }\n\n validateRadioProtocol(config: unknown): ValidationResult {\n return this.validateAgainst(radioProtocolSchema, config);\n }\n\n validateMemoryMap(memoryMap: unknown): ValidationResult {\n return this.validateAgainst(radioMemoryMapSchema, memoryMap);\n }\n\n private validateAgainst(schema: object, data: unknown): ValidationResult {\n const validate = this.ajv.compile(schema);\n const valid = validate(data);\n\n if (valid) {\n return { valid: true };\n }\n\n let errors: string[] = [];\n if (validate.errors && validate.errors.length > 0) {\n errors = validate.errors.map((error: AjvError) => {\n const path = error.instancePath || 'root';\n return `${path}: ${error.message}`;\n });\n }\n\n return {\n errors,\n valid: false,\n };\n }\n}\n"]}
|
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
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * from './memory/segmented-memory.js';
|
|
2
|
+
export * from './memory/memory-map-codec.js';
|
|
3
|
+
export * from './memory/memory-map-channels.js';
|
|
4
|
+
export * from './memory/memory-map-ui.js';
|
|
2
5
|
|
|
3
6
|
export * from './test-utils/radio-channel-factory.js';
|
|
4
7
|
export * from './test-utils/radio-programmed-channel-factory.js';
|
|
@@ -15,3 +18,4 @@ export * from './utils/ui-logger-factory.js';
|
|
|
15
18
|
|
|
16
19
|
// Export the radio protocol schema
|
|
17
20
|
export { default as radioProtocolSchema } from './schemas/radio-protocol-schema.json' with { type: 'json' };
|
|
21
|
+
export { default as radioMemoryMapSchema } from './schemas/radio-memory-map-schema.json' with { type: 'json' };
|
|
@@ -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
|
+
}
|