@springfield/ham-radio-registry 1.0.1

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.
@@ -0,0 +1,379 @@
1
+ import type { ILogLayer } from 'loglayer';
2
+ import type { RadioModelId, RadioCodec, ValidationResult } from '@springfield/ham-radio-api';
3
+ import type { RegistryRadio } from '../types/radio-config.js';
4
+ import type { PluginModule } from '../types/plugin-module.js';
5
+ import type { SharedComponentManager } from '@springfield/ham-radio-api';
6
+ import type { NpmClient } from '../utils/npm-client.js';
7
+ import { DefaultSharedComponentManager } from './shared-components.js';
8
+ import { DefaultNpmClient } from '../utils/npm-client.js';
9
+ import { readdir, readFile } from 'fs/promises';
10
+ import { join } from 'path';
11
+ import { existsSync } from 'fs';
12
+
13
+ /**
14
+ * Radio configuration registry interface
15
+ */
16
+ export interface RadioConfigRegistry {
17
+ // Discover all available radio configurations from npm modules
18
+ discoverConfigurations(): Promise<RegistryRadio[]>;
19
+
20
+ // Get configuration by ID
21
+ getConfiguration(configId: string): Promise<RegistryRadio | null>;
22
+
23
+ // Get configurations by manufacturer
24
+ getConfigurationsByManufacturer(manufacturer: string): Promise<RegistryRadio[]>;
25
+
26
+ // Get configurations by module
27
+ getConfigurationsByModule(moduleId: string): Promise<RegistryRadio[]>;
28
+
29
+ // Validate configuration
30
+ validateConfiguration(config: RegistryRadio): ValidationResult;
31
+
32
+ // Register a new configuration
33
+ registerConfiguration(config: RegistryRadio): Promise<void>;
34
+
35
+ // Install and load a new plugin module
36
+ installPlugin(moduleId: string): Promise<void>;
37
+
38
+ // List installed plugin modules
39
+ listInstalledPlugins(): Promise<PluginModule[]>;
40
+
41
+ // Get codec for a radio model
42
+ getCodec(modelId: RadioModelId): Promise<RadioCodec | null>;
43
+ }
44
+
45
+ /**
46
+ * NPM-based configuration registry implementation
47
+ */
48
+ export class NpmBasedConfigRegistry implements RadioConfigRegistry {
49
+ private configCache = new Map<string, RegistryRadio>();
50
+ private pluginCache = new Map<string, PluginModule>();
51
+ private codecCache = new Map<string, RadioCodec>();
52
+ private sharedComponentManager: SharedComponentManager;
53
+ private npmClient: NpmClient;
54
+ private logger: ILogLayer;
55
+
56
+ constructor(logger: ILogLayer) {
57
+ this.logger = logger;
58
+ this.sharedComponentManager = new DefaultSharedComponentManager(logger);
59
+ this.npmClient = new DefaultNpmClient(logger);
60
+ }
61
+
62
+ async discoverConfigurations(): Promise<RegistryRadio[]> {
63
+ const configs: RegistryRadio[] = [];
64
+
65
+ // Discover plugin modules from node_modules
66
+ const pluginModules = await this.discoverPluginModules();
67
+
68
+ for (const plugin of pluginModules) {
69
+ try {
70
+ const pluginConfigs = await this.loadConfigurationsFromPlugin(plugin);
71
+ configs.push(...pluginConfigs);
72
+ } catch (error) {
73
+ this.logger.withError(error).warn('Failed to load configurations from plugin ' + plugin.name);
74
+ }
75
+ }
76
+
77
+ return configs;
78
+ }
79
+
80
+ async getConfiguration(configId: string): Promise<RegistryRadio | null> {
81
+ // Check cache first
82
+ if (this.configCache.has(configId)) {
83
+ return this.configCache.get(configId)!;
84
+ }
85
+
86
+ // Discover configurations if cache is empty
87
+ if (this.configCache.size === 0) {
88
+ await this.discoverConfigurations();
89
+ }
90
+
91
+ return this.configCache.get(configId) || null;
92
+ }
93
+
94
+ async getConfigurationsByManufacturer(manufacturer: string): Promise<RegistryRadio[]> {
95
+ const configs = await this.discoverConfigurations();
96
+ return configs.filter((config) => config.id.manufacturer.toLowerCase() === manufacturer.toLowerCase());
97
+ }
98
+
99
+ async getConfigurationsByModule(moduleId: string): Promise<RegistryRadio[]> {
100
+ const configs = await this.discoverConfigurations();
101
+ return configs.filter((config) => config.metadata.moduleId === moduleId);
102
+ }
103
+
104
+ validateConfiguration(config: RegistryRadio): ValidationResult {
105
+ const errors: string[] = [];
106
+ const warnings: string[] = [];
107
+
108
+ // Basic validation
109
+ if (!config.id?.model) {
110
+ errors.push('Configuration must have a valid model ID');
111
+ }
112
+
113
+ if (!config.serialConfig) {
114
+ errors.push('Configuration must have serial configuration');
115
+ }
116
+
117
+ if (!config.memoryConfig) {
118
+ errors.push('Configuration must have memory configuration');
119
+ }
120
+
121
+ if (!config.readMemory || config.readMemory.length === 0) {
122
+ errors.push('Configuration must have read memory protocol');
123
+ }
124
+
125
+ if (!config.writeMemory || config.writeMemory.length === 0) {
126
+ errors.push('Configuration must have write memory protocol');
127
+ }
128
+
129
+ // Schema validation
130
+ if (!config.settingsSchema) {
131
+ errors.push('Configuration must have settings schema');
132
+ }
133
+
134
+ return {
135
+ isValid: errors.length === 0,
136
+ errors,
137
+ warnings,
138
+ };
139
+ }
140
+
141
+ async registerConfiguration(config: RegistryRadio): Promise<void> {
142
+ const validation = this.validateConfiguration(config);
143
+ if (!validation.isValid) {
144
+ throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`);
145
+ }
146
+
147
+ this.configCache.set(config.id.model, config);
148
+ this.logger.withMetadata({ modelId: config.id.model }).info('Registered configuration');
149
+ }
150
+
151
+ async installPlugin(moduleId: string): Promise<void> {
152
+ // Validate plugin before installation
153
+ const validation = await this.validatePlugin(moduleId);
154
+ if (!validation.isValid) {
155
+ throw new Error(`Plugin validation failed: ${validation.errors.join(', ')}`);
156
+ }
157
+
158
+ // Install using yarn
159
+ await this.runPackageManager(['add', moduleId]);
160
+
161
+ // Refresh configuration registry
162
+ await this.discoverConfigurations();
163
+
164
+ this.logger.withMetadata({ moduleId }).info('Installed plugin');
165
+ }
166
+
167
+ async listInstalledPlugins(): Promise<PluginModule[]> {
168
+ return Array.from(this.pluginCache.values());
169
+ }
170
+
171
+ async getCodec(modelId: RadioModelId): Promise<RadioCodec | null> {
172
+ // Check cache first
173
+ if (this.codecCache.has(modelId)) {
174
+ return this.codecCache.get(modelId)!;
175
+ }
176
+
177
+ // Find configuration for this model
178
+ const config = await this.getConfiguration(modelId);
179
+ if (!config || !config.codec) {
180
+ return null;
181
+ }
182
+
183
+ // Load codec based on configuration
184
+ const codec = await this.loadCodecFromConfig(config);
185
+ if (codec) {
186
+ this.codecCache.set(modelId, codec);
187
+ }
188
+
189
+ return codec;
190
+ }
191
+
192
+ private async discoverPluginModules(): Promise<PluginModule[]> {
193
+ const plugins: PluginModule[] = [];
194
+
195
+ // Scan node_modules for radio modules
196
+ const nodeModulesPath = join(process.cwd(), 'node_modules');
197
+
198
+ if (!existsSync(nodeModulesPath)) {
199
+ return plugins;
200
+ }
201
+
202
+ const entries = await readdir(nodeModulesPath, { withFileTypes: true });
203
+
204
+ for (const entry of entries) {
205
+ if (entry.isDirectory()) {
206
+ const packageJsonPath = join(nodeModulesPath, entry.name, 'package.json');
207
+
208
+ try {
209
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
210
+
211
+ // Check if this is a radio module
212
+ if (this.isRadioModule(packageJson)) {
213
+ const plugin = await this.loadPluginModule(entry.name, packageJson);
214
+ plugins.push(plugin);
215
+ this.pluginCache.set(entry.name, plugin);
216
+ }
217
+ } catch (error) {
218
+ // Skip invalid packages
219
+ this.logger.withMetadata({ name: entry.name, error } as any).debug('Skipped invalid package');
220
+ }
221
+ }
222
+ }
223
+
224
+ return plugins;
225
+ }
226
+
227
+ private isRadioModule(packageJson: any): boolean {
228
+ const name = packageJson.name || '';
229
+
230
+ // Check naming convention
231
+ const isNamedCorrectly = name.includes('radio-module') || name.startsWith('@springfield/radio-module-');
232
+
233
+ // Check springfield plugin field
234
+ const hasSpringfieldField = packageJson.springfield?.pluginType === 'radio-module';
235
+
236
+ // Check keywords
237
+ const hasKeywords = packageJson.keywords?.includes('radio-module');
238
+
239
+ return isNamedCorrectly || hasSpringfieldField || hasKeywords;
240
+ }
241
+
242
+ private async loadPluginModule(moduleName: string, packageJson: any): Promise<PluginModule> {
243
+ const modulePath = join(process.cwd(), 'node_modules', moduleName);
244
+ const springfieldConfig = packageJson.springfield || {};
245
+
246
+ return {
247
+ name: moduleName,
248
+ version: packageJson.version,
249
+ manufacturer: springfieldConfig.manufacturer,
250
+ configPath: join(modulePath, springfieldConfig.configPath || 'configs'),
251
+ sharedPath: join(modulePath, springfieldConfig.sharedPath || 'shared'),
252
+ codecFactoryPath: springfieldConfig.codecFactory ? join(modulePath, springfieldConfig.codecFactory) : undefined,
253
+ capabilities: springfieldConfig.capabilities || {},
254
+ packageJson,
255
+ } as PluginModule;
256
+ }
257
+
258
+ private async loadConfigurationsFromPlugin(plugin: PluginModule): Promise<RegistryRadio[]> {
259
+ const configs: RegistryRadio[] = [];
260
+
261
+ try {
262
+ const configFiles = await this.findConfigFiles(plugin.configPath);
263
+
264
+ for (const configFile of configFiles) {
265
+ try {
266
+ const config = await this.loadConfiguration(configFile);
267
+
268
+ // Resolve shared component references
269
+ await this.resolveSharedComponents(config, plugin);
270
+
271
+ // Add plugin metadata
272
+ config.metadata = {
273
+ ...config.metadata,
274
+ moduleId: plugin.name,
275
+ moduleVersion: plugin.version,
276
+ pluginPath: plugin.configPath,
277
+ };
278
+
279
+ if (this.validateConfiguration(config).isValid) {
280
+ configs.push(config);
281
+ this.configCache.set(config.id.model, config);
282
+ }
283
+ } catch (error) {
284
+ this.logger.withError(error).warn(`Failed to load configuration from ${configFile}:`);
285
+ }
286
+ }
287
+ } catch (error) {
288
+ this.logger.withError(error).warn(`Failed to access plugin directory ${plugin.configPath}:`);
289
+ }
290
+
291
+ return configs;
292
+ }
293
+
294
+ private async findConfigFiles(configPath: string): Promise<string[]> {
295
+ if (!existsSync(configPath)) {
296
+ return [];
297
+ }
298
+
299
+ const files = await readdir(configPath);
300
+ return files.filter((file) => file.endsWith('.json')).map((file) => join(configPath, file));
301
+ }
302
+
303
+ private async loadConfiguration(configFile: string): Promise<RegistryRadio> {
304
+ const content = await readFile(configFile, 'utf8');
305
+ return JSON.parse(content);
306
+ }
307
+
308
+ private async resolveSharedComponents(config: RegistryRadio, plugin: PluginModule): Promise<void> {
309
+ // Resolve schema references
310
+ if (config.settingsSchema.settingsSchema && typeof config.settingsSchema.settingsSchema === 'object' && '$ref' in config.settingsSchema.settingsSchema) {
311
+ const schemaPath = this.sharedComponentManager.resolveReference(config.settingsSchema.settingsSchema.$ref, plugin.configPath);
312
+ config.settingsSchema.settingsSchema = await this.sharedComponentManager.loadSchema(schemaPath);
313
+ }
314
+
315
+ if (config.settingsSchema.channelSchema && typeof config.settingsSchema.channelSchema === 'object' && '$ref' in config.settingsSchema.channelSchema) {
316
+ const schemaPath = this.sharedComponentManager.resolveReference(config.settingsSchema.channelSchema.$ref, plugin.configPath);
317
+ config.settingsSchema.channelSchema = await this.sharedComponentManager.loadSchema(schemaPath);
318
+ }
319
+ }
320
+
321
+ private async loadCodecFromConfig(config: RegistryRadio): Promise<RadioCodec | null> {
322
+ if (!config.codec || config.codec.type !== 'shared' || !config.codec.reference) {
323
+ return null;
324
+ }
325
+
326
+ const codecPath = this.sharedComponentManager.resolveReference(config.codec.reference, config.metadata.pluginPath || '');
327
+
328
+ return this.sharedComponentManager.loadCodec(codecPath, {
329
+ modelId: config.id.model,
330
+ ...config.codec.config,
331
+ });
332
+ }
333
+
334
+ private async validatePlugin(moduleId: string): Promise<ValidationResult> {
335
+ const errors: string[] = [];
336
+ const warnings: string[] = [];
337
+
338
+ try {
339
+ // Get package info from npm registry
340
+ const packageInfo = await this.npmClient.getPackageInfo(moduleId);
341
+
342
+ // Check if it's a radio module
343
+ if (!this.isRadioModule(packageInfo)) {
344
+ errors.push('Package is not a radio module');
345
+ }
346
+
347
+ // Check version compatibility
348
+ if (!this.isVersionCompatible()) {
349
+ errors.push('Module version is not compatible with current system');
350
+ }
351
+ } catch (error) {
352
+ errors.push(`Failed to validate plugin: ${error}`);
353
+ }
354
+
355
+ return {
356
+ isValid: errors.length === 0,
357
+ errors,
358
+ warnings,
359
+ };
360
+ }
361
+
362
+ private isVersionCompatible(): boolean {
363
+ // Basic version compatibility check
364
+ // Could be enhanced with semver validation
365
+ return true;
366
+ }
367
+
368
+ private async runPackageManager(args: string[]): Promise<void> {
369
+ const { exec } = await import('child_process');
370
+ const { promisify } = await import('util');
371
+ const execAsync = promisify(exec);
372
+
373
+ try {
374
+ await execAsync(`yarn ${args.join(' ')}`);
375
+ } catch (error) {
376
+ throw new Error(`Package manager error: ${error}`);
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,80 @@
1
+ import type { ILogLayer } from 'loglayer';
2
+ import { readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import type { RadioCodec, SharedComponentManager } from '@springfield/ham-radio-api';
5
+
6
+ /**
7
+ * Default implementation of the shared component manager
8
+ */
9
+ export class DefaultSharedComponentManager implements SharedComponentManager {
10
+ private logger: ILogLayer;
11
+
12
+ constructor(logger: ILogLayer) {
13
+ this.logger = logger;
14
+ }
15
+
16
+ async loadSchema(schemaPath: string): Promise<Record<string, unknown>> {
17
+ try {
18
+ const content = await readFile(schemaPath, 'utf8');
19
+ const schema = JSON.parse(content);
20
+
21
+ this.logger.withMetadata({ schemaPath }).debug('Loaded shared schema');
22
+ return schema;
23
+ } catch (error) {
24
+ this.logger.withError(error).error('Failed to load shared schema');
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async loadProtocol(protocolPath: string): Promise<Record<string, unknown>> {
30
+ try {
31
+ const content = await readFile(protocolPath, 'utf8');
32
+ const protocol = JSON.parse(content);
33
+
34
+ this.logger.withMetadata({ protocolPath }).debug('Loaded shared protocol');
35
+ return protocol;
36
+ } catch (error) {
37
+ this.logger.withError(error).error('Failed to load shared protocol');
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async loadCodec(codecPath: string, config: Record<string, unknown>): Promise<RadioCodec> {
43
+ try {
44
+ // Dynamically load codec module
45
+ const codecModule = await import(codecPath);
46
+
47
+ // Check for CodecFactory export
48
+ if (!codecModule.CodecFactory) {
49
+ throw new Error(`Codec module does not export CodecFactory: ${codecPath}`);
50
+ }
51
+
52
+ const CodecFactory = codecModule.CodecFactory;
53
+ const factory = new CodecFactory();
54
+
55
+ // Create codec with configuration
56
+ const codec = await factory.createCodec(config.modelId as any, config, this.logger);
57
+
58
+ this.logger.withMetadata({ codecPath, modelId: config.modelId }).info('Loaded shared codec');
59
+ return codec;
60
+ } catch (error) {
61
+ this.logger.withError(error).error('Failed to load shared codec');
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ resolveReference(reference: string, basePath: string): string {
67
+ // Handle relative paths
68
+ if (reference.startsWith('./') || reference.startsWith('../')) {
69
+ return join(basePath, reference);
70
+ }
71
+
72
+ // Handle absolute paths
73
+ if (reference.startsWith('/')) {
74
+ return reference;
75
+ }
76
+
77
+ // Default to relative path
78
+ return join(basePath, reference);
79
+ }
80
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Plugin module capabilities
3
+ */
4
+ export interface PluginCapabilities {
5
+ dslProtocols: boolean;
6
+ customCodecs: boolean;
7
+ memoryRead: boolean;
8
+ memoryWrite: boolean;
9
+ sharedComponents: boolean;
10
+ }
11
+
12
+ /**
13
+ * Springfield-specific plugin configuration in package.json
14
+ */
15
+ export interface SpringfieldPluginConfig {
16
+ pluginType: 'radio-module';
17
+ version: string;
18
+ manufacturer: string;
19
+ supportedRadios: string[];
20
+ capabilities: PluginCapabilities;
21
+ configPath: string;
22
+ sharedPath: string;
23
+ codecFactory?: string;
24
+ }
25
+
26
+ /**
27
+ * Plugin module information
28
+ */
29
+ export interface PluginModule {
30
+ name: string;
31
+ version: string;
32
+ manufacturer?: string;
33
+ configPath: string;
34
+ sharedPath: string;
35
+ codecFactoryPath?: string;
36
+ capabilities: Record<string, boolean>;
37
+ packageJson: any;
38
+ }
39
+
40
+ /**
41
+ * NPM plugin information from registry
42
+ */
43
+ export interface NpmPluginInfo {
44
+ name: string;
45
+ version: string;
46
+ description: string;
47
+ author: string;
48
+ license: string;
49
+ repository?: string;
50
+ downloads: number;
51
+ lastUpdated: string;
52
+ springfield?: SpringfieldPluginConfig;
53
+ }
54
+
55
+ /**
56
+ * Plugin manifest configuration
57
+ */
58
+ export interface PluginManifest {
59
+ pluginType: 'radio-module';
60
+ version: string;
61
+ manufacturer: string;
62
+ author: string;
63
+ license: string;
64
+ repository?: string;
65
+ configurations: PluginManifestConfiguration[];
66
+ sharedComponents?: PluginSharedComponents;
67
+ security?: PluginSecurity;
68
+ }
69
+
70
+ /**
71
+ * Plugin manifest configuration entry
72
+ */
73
+ export interface PluginManifestConfiguration {
74
+ id: string;
75
+ name: string;
76
+ file: string;
77
+ description: string;
78
+ usesSharedComponents?: string[];
79
+ }
80
+
81
+ /**
82
+ * Plugin shared components
83
+ */
84
+ export interface PluginSharedComponents {
85
+ schemas?: Record<string, string>;
86
+ protocols?: Record<string, string>;
87
+ codecs?: Record<string, string>;
88
+ }
89
+
90
+ /**
91
+ * Plugin security information
92
+ */
93
+ export interface PluginSecurity {
94
+ signature?: string;
95
+ checksum?: string;
96
+ }
@@ -0,0 +1,43 @@
1
+ import type { Radio } from '@springfield/ham-radio-api';
2
+
3
+ /**
4
+ * Radio configuration metadata
5
+ */
6
+ export interface RadioConfigMetadata {
7
+ moduleId: string;
8
+ moduleVersion: string;
9
+ pluginPath: string;
10
+ lastUpdated?: string;
11
+ author?: string;
12
+ license?: string;
13
+ }
14
+
15
+ /**
16
+ * Radio capabilities
17
+ */
18
+ export interface RadioCapabilities {
19
+ dslProtocols: boolean;
20
+ customCodecs: boolean;
21
+ memoryRead: boolean;
22
+ memoryWrite: boolean;
23
+ sharedComponents: boolean;
24
+ }
25
+
26
+ /**
27
+ * Codec configuration
28
+ */
29
+ export interface CodecConfig {
30
+ type: 'shared' | 'inline';
31
+ reference?: string;
32
+ config?: Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Registry radio configuration
37
+ */
38
+ export interface RegistryRadio extends Radio {
39
+ $schema?: string;
40
+ capabilities: RadioCapabilities;
41
+ codec?: CodecConfig;
42
+ metadata: RadioConfigMetadata;
43
+ }