@tx5dr/contracts 1.7.7 → 1.7.9
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/schema/__tests__/ability.test.js +47 -1
- package/dist/schema/__tests__/ability.test.js.map +1 -1
- package/dist/schema/__tests__/audio.schema.test.js +14 -1
- package/dist/schema/__tests__/audio.schema.test.js.map +1 -1
- package/dist/schema/__tests__/plugin-market.schema.test.js +1 -0
- package/dist/schema/__tests__/plugin-market.schema.test.js.map +1 -1
- package/dist/schema/ability.d.ts +10 -0
- package/dist/schema/ability.d.ts.map +1 -1
- package/dist/schema/ability.js +105 -0
- package/dist/schema/ability.js.map +1 -1
- package/dist/schema/audio.schema.d.ts +30 -0
- package/dist/schema/audio.schema.d.ts.map +1 -1
- package/dist/schema/audio.schema.js +4 -0
- package/dist/schema/audio.schema.js.map +1 -1
- package/dist/schema/plugin.schema.d.ts +79 -77
- package/dist/schema/plugin.schema.d.ts.map +1 -1
- package/dist/schema/plugin.schema.js +4 -0
- package/dist/schema/plugin.schema.js.map +1 -1
- package/dist/schema/radio-profile.schema.d.ts +86 -0
- package/dist/schema/radio-profile.schema.d.ts.map +1 -1
- package/dist/schema/websocket.schema.d.ts +94 -94
- package/package.json +1 -1
- package/src/schema/__tests__/ability.test.ts +59 -0
- package/src/schema/__tests__/audio.schema.test.ts +18 -0
- package/src/schema/__tests__/plugin-market.schema.test.ts +1 -0
- package/src/schema/ability.ts +130 -0
- package/src/schema/audio.schema.ts +7 -0
- package/src/schema/plugin.schema.ts +4 -0
- package/test/plugin-slot-schema.test.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
|
+
buildRadioFrequencyPermissionGrants,
|
|
3
4
|
buildAbilityRules,
|
|
5
|
+
getPresetFrequenciesFromFrequencyGrants,
|
|
6
|
+
getRangesFromFrequencyGrants,
|
|
4
7
|
Permission,
|
|
5
8
|
PERMISSION_GROUPS,
|
|
6
9
|
PERMISSION_RULE_MAP,
|
|
@@ -37,4 +40,60 @@ describe('ability contracts', () => {
|
|
|
37
40
|
Permission.CW_DECODER_CONFIG,
|
|
38
41
|
]);
|
|
39
42
|
});
|
|
43
|
+
|
|
44
|
+
it('builds preset and range frequency grants as separate OR rules', () => {
|
|
45
|
+
const grants = buildRadioFrequencyPermissionGrants(
|
|
46
|
+
[7_050_000, 7_050_000],
|
|
47
|
+
[{ minFrequency: 14_000_000, maxFrequency: 14_350_000 }],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(grants).toEqual([
|
|
51
|
+
{
|
|
52
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
53
|
+
conditions: { frequency: { $in: [7_050_000] } },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
57
|
+
conditions: { frequency: { $gte: 14_000_000, $lte: 14_350_000 } },
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses an unconditional frequency grant when no preset or range restriction is provided', () => {
|
|
63
|
+
expect(buildRadioFrequencyPermissionGrants([], [])).toEqual([
|
|
64
|
+
{ permission: Permission.RADIO_SET_FREQUENCY },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects invalid preset frequency restrictions instead of allowing all frequencies', () => {
|
|
69
|
+
expect(() => buildRadioFrequencyPermissionGrants([Number.NaN], []))
|
|
70
|
+
.toThrow('presetFrequencies[0]');
|
|
71
|
+
expect(() => buildRadioFrequencyPermissionGrants([0], []))
|
|
72
|
+
.toThrow('presetFrequencies[0]');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects invalid range restrictions instead of allowing all frequencies', () => {
|
|
76
|
+
expect(() => buildRadioFrequencyPermissionGrants([], [{ minFrequency: Number.NaN, maxFrequency: 14_350_000 }]))
|
|
77
|
+
.toThrow('ranges[0].minFrequency');
|
|
78
|
+
expect(() => buildRadioFrequencyPermissionGrants([], [{ minFrequency: 14_350_000, maxFrequency: 14_000_000 }]))
|
|
79
|
+
.toThrow('ranges[0]');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('restores preset and range restrictions from existing frequency grants', () => {
|
|
83
|
+
const grants = [
|
|
84
|
+
{
|
|
85
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
86
|
+
conditions: { frequency: { $in: [7_050_000, 14_270_000] } },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
90
|
+
conditions: { frequency: { $gte: 14_000_000, $lte: 14_350_000 } },
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
expect(getPresetFrequenciesFromFrequencyGrants(grants)).toEqual([7_050_000, 14_270_000]);
|
|
95
|
+
expect(getRangesFromFrequencyGrants(grants)).toEqual([
|
|
96
|
+
{ band: '20m', minFrequency: 14_000_000, maxFrequency: 14_350_000 },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
40
99
|
});
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
AudioDevicesResponseSchema,
|
|
4
4
|
AudioDeviceResolutionSchema,
|
|
5
5
|
AudioDeviceResolutionStatusSchema,
|
|
6
|
+
AudioDeviceSettingsSchema,
|
|
6
7
|
AudioDeviceSettingsResponseSchema,
|
|
8
|
+
AudioOutputChannelModeSchema,
|
|
9
|
+
AudioOutputSampleFormatSchema,
|
|
7
10
|
AudioSettingsResolveResponseSchema,
|
|
8
11
|
} from '../audio.schema.js';
|
|
9
12
|
|
|
@@ -122,6 +125,21 @@ describe('audio device resolution schemas', () => {
|
|
|
122
125
|
}).currentSettings.outputBufferSize).toBe(1024);
|
|
123
126
|
});
|
|
124
127
|
|
|
128
|
+
it('accepts and validates output diagnostic audio settings', () => {
|
|
129
|
+
expect(AudioOutputSampleFormatSchema.parse('int16')).toBe('int16');
|
|
130
|
+
expect(AudioOutputChannelModeSchema.parse('both')).toBe('both');
|
|
131
|
+
expect(AudioDeviceSettingsSchema.parse({
|
|
132
|
+
outputSampleFormat: 'int16',
|
|
133
|
+
outputChannelMode: 'right',
|
|
134
|
+
})).toMatchObject({
|
|
135
|
+
outputSampleFormat: 'int16',
|
|
136
|
+
outputChannelMode: 'right',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(() => AudioDeviceSettingsSchema.parse({ outputSampleFormat: 'int24' })).toThrow();
|
|
140
|
+
expect(() => AudioDeviceSettingsSchema.parse({ outputChannelMode: 'stereo' })).toThrow();
|
|
141
|
+
});
|
|
142
|
+
|
|
125
143
|
it('accepts resolve responses with every supported status', () => {
|
|
126
144
|
for (const status of ['selected', 'default', 'virtual-selected', 'missing'] as const) {
|
|
127
145
|
expect(AudioSettingsResolveResponseSchema.parse({
|
|
@@ -60,6 +60,7 @@ describe('plugin market schema', () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('accepts host settings permissions', () => {
|
|
63
|
+
expect(PluginPermissionSchema.parse('host:hamlib')).toBe('host:hamlib');
|
|
63
64
|
expect(PluginPermissionSchema.parse('settings:ft8')).toBe('settings:ft8');
|
|
64
65
|
expect(PluginPermissionSchema.parse('settings:ntp')).toBe('settings:ntp');
|
|
65
66
|
});
|
package/src/schema/ability.ts
CHANGED
|
@@ -100,6 +100,136 @@ export const PermissionGrantSchema = z.object({
|
|
|
100
100
|
|
|
101
101
|
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
|
|
102
102
|
|
|
103
|
+
// ===== Frequency permission helpers =====
|
|
104
|
+
|
|
105
|
+
export interface FrequencyPermissionRange {
|
|
106
|
+
band?: string;
|
|
107
|
+
minFrequency: number;
|
|
108
|
+
maxFrequency: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const FREQUENCY_PERMISSION_BAND_RANGES: Record<string, FrequencyPermissionRange> = {
|
|
112
|
+
'160m': { band: '160m', minFrequency: 1_800_000, maxFrequency: 2_000_000 },
|
|
113
|
+
'80m': { band: '80m', minFrequency: 3_500_000, maxFrequency: 4_000_000 },
|
|
114
|
+
'60m': { band: '60m', minFrequency: 5_000_000, maxFrequency: 5_500_000 },
|
|
115
|
+
'40m': { band: '40m', minFrequency: 7_000_000, maxFrequency: 7_300_000 },
|
|
116
|
+
'30m': { band: '30m', minFrequency: 10_100_000, maxFrequency: 10_150_000 },
|
|
117
|
+
'20m': { band: '20m', minFrequency: 14_000_000, maxFrequency: 14_350_000 },
|
|
118
|
+
'17m': { band: '17m', minFrequency: 18_068_000, maxFrequency: 18_168_000 },
|
|
119
|
+
'15m': { band: '15m', minFrequency: 21_000_000, maxFrequency: 21_450_000 },
|
|
120
|
+
'12m': { band: '12m', minFrequency: 24_890_000, maxFrequency: 24_990_000 },
|
|
121
|
+
'10m': { band: '10m', minFrequency: 28_000_000, maxFrequency: 29_700_000 },
|
|
122
|
+
'6m': { band: '6m', minFrequency: 50_000_000, maxFrequency: 54_000_000 },
|
|
123
|
+
'2m': { band: '2m', minFrequency: 144_000_000, maxFrequency: 148_000_000 },
|
|
124
|
+
'70cm': { band: '70cm', minFrequency: 420_000_000, maxFrequency: 450_000_000 },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function getFrequencyCondition(grant: PermissionGrant): Record<string, unknown> | null {
|
|
128
|
+
if (grant.permission !== Permission.RADIO_SET_FREQUENCY) return null;
|
|
129
|
+
const frequency = grant.conditions?.frequency;
|
|
130
|
+
if (!frequency || typeof frequency !== 'object' || Array.isArray(frequency)) return null;
|
|
131
|
+
return frequency as Record<string, unknown>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeFrequency(value: unknown): number | null {
|
|
135
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
136
|
+
? Math.round(value)
|
|
137
|
+
: null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function requireFrequency(value: unknown, label: string): number {
|
|
141
|
+
const frequency = normalizeFrequency(value);
|
|
142
|
+
if (frequency === null) {
|
|
143
|
+
throw new Error(`${label} must be a finite positive frequency in Hz`);
|
|
144
|
+
}
|
|
145
|
+
return frequency;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getPresetFrequenciesFromFrequencyGrants(grants: PermissionGrant[] | undefined): number[] {
|
|
149
|
+
const result: number[] = [];
|
|
150
|
+
const seen = new Set<number>();
|
|
151
|
+
|
|
152
|
+
for (const grant of grants ?? []) {
|
|
153
|
+
const condition = getFrequencyCondition(grant);
|
|
154
|
+
const values = condition?.$in;
|
|
155
|
+
if (!Array.isArray(values)) continue;
|
|
156
|
+
|
|
157
|
+
for (const value of values) {
|
|
158
|
+
const frequency = normalizeFrequency(value);
|
|
159
|
+
if (frequency !== null && !seen.has(frequency)) {
|
|
160
|
+
seen.add(frequency);
|
|
161
|
+
result.push(frequency);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getRangesFromFrequencyGrants(grants: PermissionGrant[] | undefined): FrequencyPermissionRange[] {
|
|
170
|
+
const result: FrequencyPermissionRange[] = [];
|
|
171
|
+
|
|
172
|
+
for (const grant of grants ?? []) {
|
|
173
|
+
const condition = getFrequencyCondition(grant);
|
|
174
|
+
const minFrequency = normalizeFrequency(condition?.$gte);
|
|
175
|
+
const maxFrequency = normalizeFrequency(condition?.$lte);
|
|
176
|
+
if (minFrequency === null || maxFrequency === null || minFrequency > maxFrequency) continue;
|
|
177
|
+
|
|
178
|
+
result.push({
|
|
179
|
+
minFrequency,
|
|
180
|
+
maxFrequency,
|
|
181
|
+
band: inferFrequencyPermissionBand(minFrequency, maxFrequency),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function inferFrequencyPermissionBand(minFrequency: number, maxFrequency: number): string | undefined {
|
|
189
|
+
const entry = Object.entries(FREQUENCY_PERMISSION_BAND_RANGES)
|
|
190
|
+
.find(([, range]) => range.minFrequency === minFrequency && range.maxFrequency === maxFrequency);
|
|
191
|
+
return entry?.[0];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildRadioFrequencyPermissionGrants(
|
|
195
|
+
presetFrequencies: number[],
|
|
196
|
+
ranges: FrequencyPermissionRange[],
|
|
197
|
+
): PermissionGrant[] {
|
|
198
|
+
const grants: PermissionGrant[] = [];
|
|
199
|
+
const normalizedPresetFrequencies = [...new Set(presetFrequencies.map((value, index) => requireFrequency(value, `presetFrequencies[${index}]`)))];
|
|
200
|
+
|
|
201
|
+
if (normalizedPresetFrequencies.length > 0) {
|
|
202
|
+
grants.push({
|
|
203
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
204
|
+
conditions: {
|
|
205
|
+
frequency: { $in: normalizedPresetFrequencies },
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const [index, range] of ranges.entries()) {
|
|
211
|
+
const minFrequency = requireFrequency(range.minFrequency, `ranges[${index}].minFrequency`);
|
|
212
|
+
const maxFrequency = requireFrequency(range.maxFrequency, `ranges[${index}].maxFrequency`);
|
|
213
|
+
if (minFrequency > maxFrequency) {
|
|
214
|
+
throw new Error(`ranges[${index}] minFrequency must be less than or equal to maxFrequency`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
grants.push({
|
|
218
|
+
permission: Permission.RADIO_SET_FREQUENCY,
|
|
219
|
+
conditions: {
|
|
220
|
+
frequency: {
|
|
221
|
+
$gte: minFrequency,
|
|
222
|
+
$lte: maxFrequency,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return grants.length > 0
|
|
229
|
+
? grants
|
|
230
|
+
: [{ permission: Permission.RADIO_SET_FREQUENCY }];
|
|
231
|
+
}
|
|
232
|
+
|
|
103
233
|
// ===== Raw rule type (pure data, no CASL dependency) =====
|
|
104
234
|
|
|
105
235
|
export interface RawRule {
|
|
@@ -34,6 +34,9 @@ export const AudioDeviceResolutionSetSchema = z.object({
|
|
|
34
34
|
output: AudioDeviceResolutionSchema,
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
export const AudioOutputSampleFormatSchema = z.enum(['float32', 'int16']);
|
|
38
|
+
export const AudioOutputChannelModeSchema = z.enum(['mono', 'left', 'right', 'both']);
|
|
39
|
+
|
|
37
40
|
// 音频设备列表响应
|
|
38
41
|
export const AudioDevicesResponseSchema = z.object({
|
|
39
42
|
inputDevices: z.array(AudioDeviceSchema),
|
|
@@ -50,6 +53,8 @@ export const AudioDeviceSettingsSchema = z.object({
|
|
|
50
53
|
outputSampleRate: z.number().optional(),
|
|
51
54
|
inputBufferSize: z.number().optional(),
|
|
52
55
|
outputBufferSize: z.number().optional(),
|
|
56
|
+
outputSampleFormat: AudioOutputSampleFormatSchema.optional(),
|
|
57
|
+
outputChannelMode: AudioOutputChannelModeSchema.optional(),
|
|
53
58
|
sampleRate: z.number().optional(),
|
|
54
59
|
bufferSize: z.number().optional(),
|
|
55
60
|
});
|
|
@@ -108,6 +113,8 @@ export type AudioDevice = z.infer<typeof AudioDeviceSchema>;
|
|
|
108
113
|
export type AudioDeviceResolutionStatus = z.infer<typeof AudioDeviceResolutionStatusSchema>;
|
|
109
114
|
export type AudioDeviceResolution = z.infer<typeof AudioDeviceResolutionSchema>;
|
|
110
115
|
export type AudioDeviceResolutionSet = z.infer<typeof AudioDeviceResolutionSetSchema>;
|
|
116
|
+
export type AudioOutputSampleFormat = z.infer<typeof AudioOutputSampleFormatSchema>;
|
|
117
|
+
export type AudioOutputChannelMode = z.infer<typeof AudioOutputChannelModeSchema>;
|
|
111
118
|
export type AudioDevicesResponse = z.infer<typeof AudioDevicesResponseSchema>;
|
|
112
119
|
export type AudioDeviceSettings = z.infer<typeof AudioDeviceSettingsSchema>;
|
|
113
120
|
export type AudioDeviceSettingsResponse = z.infer<typeof AudioDeviceSettingsResponseSchema>;
|
|
@@ -38,6 +38,7 @@ export type PluginInstanceScope = z.infer<typeof PluginInstanceScopeSchema>;
|
|
|
38
38
|
*/
|
|
39
39
|
export const PluginPermissionSchema = z.enum([
|
|
40
40
|
'network',
|
|
41
|
+
'host:hamlib',
|
|
41
42
|
'radio:read',
|
|
42
43
|
'radio:control',
|
|
43
44
|
'radio:power',
|
|
@@ -249,6 +250,8 @@ export type PluginCapability = z.infer<typeof PluginCapabilitySchema>;
|
|
|
249
250
|
* - `main-right`: shown in the optional main layout plugin pane on the far right.
|
|
250
251
|
* - `voice-left-top`: shown above the voice frequency control card.
|
|
251
252
|
* - `voice-right-top`: shown in the tabbed top area of the voice right panel.
|
|
253
|
+
* - `cw-left-top`: shown above the CW frequency control card.
|
|
254
|
+
* - `cw-right-top`: shown in the tabbed top area of the CW right panel.
|
|
252
255
|
*/
|
|
253
256
|
export const PluginPanelSlotSchema = z.enum([
|
|
254
257
|
'operator',
|
|
@@ -256,6 +259,7 @@ export const PluginPanelSlotSchema = z.enum([
|
|
|
256
259
|
'main-right',
|
|
257
260
|
'voice-left-top',
|
|
258
261
|
'voice-right-top',
|
|
262
|
+
'cw-left-top',
|
|
259
263
|
'cw-right-top',
|
|
260
264
|
'radio-control-toolbar',
|
|
261
265
|
]);
|
|
@@ -8,6 +8,7 @@ describe('PluginPanelSlotSchema', () => {
|
|
|
8
8
|
expect(PluginPanelSlotSchema.parse('main-right')).toBe('main-right');
|
|
9
9
|
expect(PluginPanelSlotSchema.parse('voice-left-top')).toBe('voice-left-top');
|
|
10
10
|
expect(PluginPanelSlotSchema.parse('voice-right-top')).toBe('voice-right-top');
|
|
11
|
+
expect(PluginPanelSlotSchema.parse('cw-left-top')).toBe('cw-left-top');
|
|
11
12
|
expect(PluginPanelSlotSchema.parse('cw-right-top')).toBe('cw-right-top');
|
|
12
13
|
expect(PluginPanelSlotSchema.parse('radio-control-toolbar')).toBe('radio-control-toolbar');
|
|
13
14
|
});
|
|
@@ -28,6 +29,14 @@ describe('PluginPanelSlotSchema', () => {
|
|
|
28
29
|
pageId: 'voice-top',
|
|
29
30
|
slot: 'voice-right-top',
|
|
30
31
|
})).not.toThrow();
|
|
32
|
+
|
|
33
|
+
expect(() => PluginPanelDescriptorSchema.parse({
|
|
34
|
+
id: 'cw-left-top',
|
|
35
|
+
title: 'cwLeftTopTitle',
|
|
36
|
+
component: 'iframe',
|
|
37
|
+
pageId: 'cw-left-top',
|
|
38
|
+
slot: 'cw-left-top',
|
|
39
|
+
})).not.toThrow();
|
|
31
40
|
});
|
|
32
41
|
|
|
33
42
|
it('validates radio control toolbar iframe panel metadata', () => {
|