@tx5dr/contracts 1.7.3 → 1.7.5

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.
Files changed (109) hide show
  1. package/dist/index.d.ts +4 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/schema/__tests__/ability.test.d.ts +2 -0
  6. package/dist/schema/__tests__/ability.test.d.ts.map +1 -0
  7. package/dist/schema/__tests__/ability.test.js +32 -0
  8. package/dist/schema/__tests__/ability.test.js.map +1 -0
  9. package/dist/schema/__tests__/cw-decoder.schema.test.d.ts +2 -0
  10. package/dist/schema/__tests__/cw-decoder.schema.test.d.ts.map +1 -0
  11. package/dist/schema/__tests__/cw-decoder.schema.test.js +114 -0
  12. package/dist/schema/__tests__/cw-decoder.schema.test.js.map +1 -0
  13. package/dist/schema/__tests__/device-ui.schema.test.d.ts +2 -0
  14. package/dist/schema/__tests__/device-ui.schema.test.d.ts.map +1 -0
  15. package/dist/schema/__tests__/device-ui.schema.test.js +111 -0
  16. package/dist/schema/__tests__/device-ui.schema.test.js.map +1 -0
  17. package/dist/schema/__tests__/process-monitor.schema.test.js +96 -0
  18. package/dist/schema/__tests__/process-monitor.schema.test.js.map +1 -1
  19. package/dist/schema/__tests__/radio.schema.test.d.ts +2 -0
  20. package/dist/schema/__tests__/radio.schema.test.d.ts.map +1 -0
  21. package/dist/schema/__tests__/radio.schema.test.js +143 -0
  22. package/dist/schema/__tests__/radio.schema.test.js.map +1 -0
  23. package/dist/schema/ability.d.ts +3 -1
  24. package/dist/schema/ability.d.ts.map +1 -1
  25. package/dist/schema/ability.js +11 -0
  26. package/dist/schema/ability.js.map +1 -1
  27. package/dist/schema/api-response.schema.d.ts +168 -1
  28. package/dist/schema/api-response.schema.d.ts.map +1 -1
  29. package/dist/schema/api-response.schema.js +22 -0
  30. package/dist/schema/api-response.schema.js.map +1 -1
  31. package/dist/schema/cpu-profile.schema.d.ts +6 -6
  32. package/dist/schema/cw-decoder.schema.d.ts +2501 -0
  33. package/dist/schema/cw-decoder.schema.d.ts.map +1 -0
  34. package/dist/schema/cw-decoder.schema.js +196 -0
  35. package/dist/schema/cw-decoder.schema.js.map +1 -0
  36. package/dist/schema/cw-keyer.schema.d.ts +368 -0
  37. package/dist/schema/cw-keyer.schema.d.ts.map +1 -0
  38. package/dist/schema/cw-keyer.schema.js +143 -0
  39. package/dist/schema/cw-keyer.schema.js.map +1 -0
  40. package/dist/schema/desktop-https.schema.js +1 -1
  41. package/dist/schema/desktop-https.schema.js.map +1 -1
  42. package/dist/schema/device-ui.schema.d.ts +1782 -0
  43. package/dist/schema/device-ui.schema.d.ts.map +1 -0
  44. package/dist/schema/device-ui.schema.js +156 -0
  45. package/dist/schema/device-ui.schema.js.map +1 -0
  46. package/dist/schema/mode.schema.d.ts +1 -0
  47. package/dist/schema/mode.schema.d.ts.map +1 -1
  48. package/dist/schema/mode.schema.js +8 -0
  49. package/dist/schema/mode.schema.js.map +1 -1
  50. package/dist/schema/plugin.schema.d.ts +905 -60
  51. package/dist/schema/plugin.schema.d.ts.map +1 -1
  52. package/dist/schema/plugin.schema.js +62 -2
  53. package/dist/schema/plugin.schema.js.map +1 -1
  54. package/dist/schema/process-monitor.schema.d.ts +1948 -261
  55. package/dist/schema/process-monitor.schema.d.ts.map +1 -1
  56. package/dist/schema/process-monitor.schema.js +11 -0
  57. package/dist/schema/process-monitor.schema.js.map +1 -1
  58. package/dist/schema/radio-capability.schema.d.ts +1 -1
  59. package/dist/schema/radio-capability.schema.d.ts.map +1 -1
  60. package/dist/schema/radio-capability.schema.js +41 -0
  61. package/dist/schema/radio-capability.schema.js.map +1 -1
  62. package/dist/schema/radio-power.schema.d.ts +6 -0
  63. package/dist/schema/radio-power.schema.d.ts.map +1 -1
  64. package/dist/schema/radio-profile.schema.d.ts +86 -0
  65. package/dist/schema/radio-profile.schema.d.ts.map +1 -1
  66. package/dist/schema/radio.schema.d.ts +160 -3
  67. package/dist/schema/radio.schema.d.ts.map +1 -1
  68. package/dist/schema/radio.schema.js +82 -0
  69. package/dist/schema/radio.schema.js.map +1 -1
  70. package/dist/schema/realtime.schema.d.ts +6 -6
  71. package/dist/schema/voice.schema.d.ts +1 -1
  72. package/dist/schema/voice.schema.d.ts.map +1 -1
  73. package/dist/schema/voice.schema.js +1 -1
  74. package/dist/schema/voice.schema.js.map +1 -1
  75. package/dist/schema/websocket.schema.d.ts +6071 -574
  76. package/dist/schema/websocket.schema.d.ts.map +1 -1
  77. package/dist/schema/websocket.schema.js +102 -2
  78. package/dist/schema/websocket.schema.js.map +1 -1
  79. package/dist/utils/__tests__/cwTiming.test.d.ts +2 -0
  80. package/dist/utils/__tests__/cwTiming.test.d.ts.map +1 -0
  81. package/dist/utils/__tests__/cwTiming.test.js +74 -0
  82. package/dist/utils/__tests__/cwTiming.test.js.map +1 -0
  83. package/dist/utils/cwTiming.d.ts +36 -0
  84. package/dist/utils/cwTiming.d.ts.map +1 -0
  85. package/dist/utils/cwTiming.js +155 -0
  86. package/dist/utils/cwTiming.js.map +1 -0
  87. package/package.json +1 -1
  88. package/src/index.ts +8 -0
  89. package/src/schema/__tests__/ability.test.ts +40 -0
  90. package/src/schema/__tests__/cw-decoder.schema.test.ts +127 -0
  91. package/src/schema/__tests__/device-ui.schema.test.ts +122 -0
  92. package/src/schema/__tests__/process-monitor.schema.test.ts +99 -0
  93. package/src/schema/__tests__/radio.schema.test.ts +160 -0
  94. package/src/schema/ability.ts +13 -0
  95. package/src/schema/api-response.schema.ts +22 -0
  96. package/src/schema/cw-decoder.schema.ts +234 -0
  97. package/src/schema/cw-keyer.schema.ts +177 -0
  98. package/src/schema/desktop-https.schema.ts +1 -1
  99. package/src/schema/device-ui.schema.ts +182 -0
  100. package/src/schema/mode.schema.ts +8 -0
  101. package/src/schema/plugin.schema.ts +80 -2
  102. package/src/schema/process-monitor.schema.ts +18 -0
  103. package/src/schema/radio-capability.schema.ts +41 -0
  104. package/src/schema/radio.schema.ts +97 -0
  105. package/src/schema/voice.schema.ts +1 -1
  106. package/src/schema/websocket.schema.ts +131 -2
  107. package/src/utils/__tests__/cwTiming.test.ts +91 -0
  108. package/src/utils/cwTiming.ts +203 -0
  109. package/test/plugin-slot-schema.test.ts +52 -0
@@ -27,6 +27,8 @@ import { RadioProfileSchema, ProfileChangedEventSchema } from './radio-profile.s
27
27
  import { UserRole } from './auth.schema.js';
28
28
  import type { VoicePTTLock } from './voice.schema.js';
29
29
  import type { VoiceKeyerStatus } from './voice-keyer.schema.js';
30
+ import type { CWKeyerStatus, CWKeyerConfig } from './cw-keyer.schema.js';
31
+ import { CWDecoderEventSchema, CWDecoderStatusSchema, type CWDecoderEvent, type CWDecoderStatus } from './cw-decoder.schema.js';
30
32
  import { CapabilityListSchema, CapabilityStateSchema, WriteCapabilityPayloadSchema } from './radio-capability.schema.js';
31
33
  import { RadioPowerStateEventSchema } from './radio-power.schema.js';
32
34
  import { AudioSidecarStatusPayloadSchema } from './audio-sidecar.schema.js';
@@ -56,11 +58,13 @@ export enum WSMessageType {
56
58
  SLOT_PACK_UPDATED = 'slotPackUpdated',
57
59
  SPECTRUM_CAPABILITIES = 'spectrumCapabilities',
58
60
  SUBSCRIBE_SPECTRUM = 'subscribeSpectrum',
61
+ SPECTRUM_SUBSCRIPTION_CHANGED = 'spectrumSubscriptionChanged',
59
62
  SPECTRUM_FRAME = 'spectrumFrame',
60
63
  SPECTRUM_SESSION_STATE_CHANGED = 'spectrumSessionStateChanged',
61
64
  INVOKE_SPECTRUM_CONTROL = 'invokeSpectrumControl',
62
65
  DECODE_ERROR = 'decodeError',
63
66
  SYSTEM_STATUS = 'systemStatus',
67
+ BOOTSTRAP_STATUS_CHANGED = 'bootstrapStatusChanged',
64
68
  CLIENT_COUNT_CHANGED = 'clientCountChanged',
65
69
  CLOCK_STATUS_CHANGED = 'clockStatusChanged',
66
70
  RIGCTLD_STATUS = 'rigctldStatus',
@@ -175,6 +179,24 @@ export enum WSMessageType {
175
179
  VOICE_KEYER_STOP = 'voiceKeyerStop',
176
180
  VOICE_KEYER_STATUS_CHANGED = 'voiceKeyerStatusChanged',
177
181
 
182
+ // ===== CW 模式 =====
183
+ /** CW 键控器状态变化(server → client) */
184
+ CW_KEYER_STATUS = 'cwKeyerStatus',
185
+ /** CW 手键动作(client → server) */
186
+ CW_KEY_ACTION = 'cwKeyAction',
187
+ /** CW 文字输入(client → server) */
188
+ CW_TEXT_INPUT = 'cwTextInput',
189
+ /** CW 播放预设报文(client → server) */
190
+ CW_PLAY_MESSAGE = 'cwPlayMessage',
191
+ /** CW 停止播放(client → server) */
192
+ CW_STOP_MESSAGE = 'cwStopMessage',
193
+ /** CW 配置变更通知(server → client) */
194
+ CW_CONFIG_CHANGED = 'cwConfigChanged',
195
+ /** CW 解码器状态快照(server → client) */
196
+ CW_DECODER_STATUS = 'cwDecoderStatus',
197
+ /** CW 解码器增量事件(server → client) */
198
+ CW_DECODER_EVENT = 'cwDecoderEvent',
199
+
178
200
  // ===== 进程监控 =====
179
201
  PROCESS_SNAPSHOT = 'processSnapshot',
180
202
  PROCESS_SNAPSHOT_HISTORY = 'processSnapshotHistory',
@@ -215,8 +237,8 @@ export const SystemStatusSchema = z.object({
215
237
  radioConnectionHealth: z.object({
216
238
  connectionHealthy: z.boolean(),
217
239
  }).optional(),
218
- /** 引擎模式:digital(FT8/FT4)或 voice(语音通联) */
219
- engineMode: z.enum(['digital', 'voice']).default('digital'),
240
+ /** 引擎模式:digital(FT8/FT4)、voice(语音通联)或 cw(莫尔斯码) */
241
+ engineMode: z.enum(['digital', 'voice', 'cw']).default('digital'),
220
242
  /** 当前电台调制模式(语音模式下使用,如 USB/LSB/FM/AM) */
221
243
  currentRadioMode: z.string().optional(),
222
244
  /** 引擎状态机当前状态 */
@@ -229,6 +251,62 @@ export const SystemStatusSchema = z.object({
229
251
  }).nullable().optional(),
230
252
  });
231
253
 
254
+ export const BootstrapLifecycleSchema = z.enum(['booting', 'completed', 'degraded', 'failed', 'dismissed']);
255
+ export const BootstrapPhaseStateSchema = z.enum([
256
+ 'pending',
257
+ 'running',
258
+ 'ready',
259
+ 'skipped',
260
+ 'warning',
261
+ 'failed',
262
+ 'timed_out',
263
+ ]);
264
+ export const BootstrapPhaseIdSchema = z.enum([
265
+ 'config-auth',
266
+ 'core-http',
267
+ 'engine-bootstrap',
268
+ 'audio-device-discovery',
269
+ 'logbook-prewarm',
270
+ 'plugin-bootstrap',
271
+ 'ntp-initial-check',
272
+ 'active-profile-autostart',
273
+ ]);
274
+
275
+ export const BootstrapPhaseStatusSchema = z.object({
276
+ id: BootstrapPhaseIdSchema,
277
+ label: z.string(),
278
+ description: z.string().optional(),
279
+ state: BootstrapPhaseStateSchema,
280
+ startedAt: z.number().optional(),
281
+ completedAt: z.number().optional(),
282
+ updatedAt: z.number(),
283
+ durationMs: z.number().optional(),
284
+ message: z.string().optional(),
285
+ retryable: z.boolean().optional(),
286
+ userVisible: z.boolean().optional(),
287
+ });
288
+
289
+ export const BootstrapStatusSchema = z.object({
290
+ bootSessionId: z.string(),
291
+ lifecycle: BootstrapLifecycleSchema,
292
+ startedAt: z.number(),
293
+ updatedAt: z.number(),
294
+ completedAt: z.number().optional(),
295
+ durationMs: z.number().optional(),
296
+ blockingReady: z.boolean().default(false),
297
+ phases: z.array(BootstrapPhaseStatusSchema),
298
+ summary: z.object({
299
+ total: z.number(),
300
+ pending: z.number(),
301
+ running: z.number(),
302
+ ready: z.number(),
303
+ skipped: z.number(),
304
+ warning: z.number(),
305
+ failed: z.number(),
306
+ timedOut: z.number(),
307
+ }),
308
+ });
309
+
232
310
  // 子窗口信息数据结构
233
311
  export const SubWindowInfoSchema = z.object({
234
312
  slotInfo: SlotInfoSchema,
@@ -387,6 +465,11 @@ export const MeterDataSchema = z.object({
387
465
 
388
466
  // ===== 导出共享类型 =====
389
467
  export type SystemStatus = z.infer<typeof SystemStatusSchema>;
468
+ export type BootstrapLifecycle = z.infer<typeof BootstrapLifecycleSchema>;
469
+ export type BootstrapPhaseState = z.infer<typeof BootstrapPhaseStateSchema>;
470
+ export type BootstrapPhaseId = z.infer<typeof BootstrapPhaseIdSchema>;
471
+ export type BootstrapPhaseStatus = z.infer<typeof BootstrapPhaseStatusSchema>;
472
+ export type BootstrapStatus = z.infer<typeof BootstrapStatusSchema>;
390
473
  export type SubWindowInfo = z.infer<typeof SubWindowInfoSchema>;
391
474
  export type DecodeErrorInfo = z.infer<typeof DecodeErrorInfoSchema>;
392
475
  export type FrequencyState = z.infer<typeof FrequencyStateSchema>;
@@ -448,6 +531,17 @@ export const WSSubscribeSpectrumMessageSchema = WSBaseMessageSchema.extend({
448
531
  }),
449
532
  });
450
533
 
534
+ export const WSSpectrumSubscriptionChangedMessageSchema = WSBaseMessageSchema.extend({
535
+ type: z.literal(WSMessageType.SPECTRUM_SUBSCRIPTION_CHANGED),
536
+ data: z.object({
537
+ requestedKind: SpectrumKindSchema.nullable(),
538
+ effectiveKind: SpectrumKindSchema.nullable(),
539
+ ok: z.boolean(),
540
+ reason: z.string().optional(),
541
+ capabilities: SpectrumCapabilitiesSchema.optional(),
542
+ }),
543
+ });
544
+
451
545
  export const WSSpectrumFrameMessageSchema = WSBaseMessageSchema.extend({
452
546
  type: z.literal(WSMessageType.SPECTRUM_FRAME),
453
547
  data: SpectrumFrameSchema,
@@ -473,6 +567,11 @@ export const WSSystemStatusMessageSchema = WSBaseMessageSchema.extend({
473
567
  data: SystemStatusSchema,
474
568
  });
475
569
 
570
+ export const WSBootstrapStatusChangedMessageSchema = WSBaseMessageSchema.extend({
571
+ type: z.literal(WSMessageType.BOOTSTRAP_STATUS_CHANGED),
572
+ data: BootstrapStatusSchema,
573
+ });
574
+
476
575
  export const WSClientCountChangedMessageSchema = WSBaseMessageSchema.extend({
477
576
  type: z.literal(WSMessageType.CLIENT_COUNT_CHANGED),
478
577
  data: z.object({
@@ -1109,6 +1208,20 @@ export const WSTextMessageSchema = WSBaseMessageSchema.extend({
1109
1208
 
1110
1209
  export type WSTextMessage = z.infer<typeof WSTextMessageSchema>;
1111
1210
 
1211
+ export const WSCWDecoderStatusMessageSchema = WSBaseMessageSchema.extend({
1212
+ type: z.literal(WSMessageType.CW_DECODER_STATUS),
1213
+ data: CWDecoderStatusSchema,
1214
+ });
1215
+
1216
+ export type WSCWDecoderStatusMessage = z.infer<typeof WSCWDecoderStatusMessageSchema>;
1217
+
1218
+ export const WSCWDecoderEventMessageSchema = WSBaseMessageSchema.extend({
1219
+ type: z.literal(WSMessageType.CW_DECODER_EVENT),
1220
+ data: CWDecoderEventSchema,
1221
+ });
1222
+
1223
+ export type WSCWDecoderEventMessage = z.infer<typeof WSCWDecoderEventMessageSchema>;
1224
+
1112
1225
  // ===== 统一电台控制能力消息 =====
1113
1226
 
1114
1227
  /**
@@ -1270,11 +1383,13 @@ export const WSMessageSchema = z.discriminatedUnion('type', [
1270
1383
  WSSlotPackUpdatedMessageSchema,
1271
1384
  WSSpectrumCapabilitiesMessageSchema,
1272
1385
  WSSubscribeSpectrumMessageSchema,
1386
+ WSSpectrumSubscriptionChangedMessageSchema,
1273
1387
  WSSpectrumFrameMessageSchema,
1274
1388
  WSSpectrumSessionStateChangedMessageSchema,
1275
1389
  WSInvokeSpectrumControlMessageSchema,
1276
1390
  WSDecodeErrorMessageSchema,
1277
1391
  WSSystemStatusMessageSchema,
1392
+ WSBootstrapStatusChangedMessageSchema,
1278
1393
  WSClientCountChangedMessageSchema,
1279
1394
  WSClockStatusChangedMessageSchema,
1280
1395
  WSErrorMessageSchema,
@@ -1347,6 +1462,10 @@ export const WSMessageSchema = z.discriminatedUnion('type', [
1347
1462
  // 文本消息(Toast通知)
1348
1463
  WSTextMessageSchema,
1349
1464
 
1465
+ // CW 解码器消息
1466
+ WSCWDecoderStatusMessageSchema,
1467
+ WSCWDecoderEventMessageSchema,
1468
+
1350
1469
  // 认证消息
1351
1470
  WSAuthRequiredMessageSchema,
1352
1471
  WSAuthTokenMessageSchema,
@@ -1378,11 +1497,13 @@ export type WSSubWindowMessage = z.infer<typeof WSSubWindowMessageSchema>;
1378
1497
  export type WSSlotPackUpdatedMessage = z.infer<typeof WSSlotPackUpdatedMessageSchema>;
1379
1498
  export type WSSpectrumCapabilitiesMessage = z.infer<typeof WSSpectrumCapabilitiesMessageSchema>;
1380
1499
  export type WSSubscribeSpectrumMessage = z.infer<typeof WSSubscribeSpectrumMessageSchema>;
1500
+ export type WSSpectrumSubscriptionChangedMessage = z.infer<typeof WSSpectrumSubscriptionChangedMessageSchema>;
1381
1501
  export type WSSpectrumFrameMessage = z.infer<typeof WSSpectrumFrameMessageSchema>;
1382
1502
  export type WSSpectrumSessionStateChangedMessage = z.infer<typeof WSSpectrumSessionStateChangedMessageSchema>;
1383
1503
  export type WSInvokeSpectrumControlMessage = z.infer<typeof WSInvokeSpectrumControlMessageSchema>;
1384
1504
  export type WSDecodeErrorMessage = z.infer<typeof WSDecodeErrorMessageSchema>;
1385
1505
  export type WSSystemStatusMessage = z.infer<typeof WSSystemStatusMessageSchema>;
1506
+ export type WSBootstrapStatusChangedMessage = z.infer<typeof WSBootstrapStatusChangedMessageSchema>;
1386
1507
  export type WSClientCountChangedMessage = z.infer<typeof WSClientCountChangedMessageSchema>;
1387
1508
  export type WSClockStatusChangedMessage = z.infer<typeof WSClockStatusChangedMessageSchema>;
1388
1509
  export type WSErrorMessage = z.infer<typeof WSErrorMessageSchema>;
@@ -1432,6 +1553,7 @@ export interface DigitalRadioEngineEvents {
1432
1553
  slotPackUpdated: (slotPack: z.infer<typeof SlotPackSchema>) => void;
1433
1554
  slotPacksReset: (data: z.infer<typeof WSSlotPacksResetMessageSchema>['data']) => void;
1434
1555
  spectrumCapabilities: (data: z.infer<typeof SpectrumCapabilitiesSchema>) => void;
1556
+ spectrumSubscriptionChanged: (data: z.infer<typeof WSSpectrumSubscriptionChangedMessageSchema>['data']) => void;
1435
1557
  spectrumFrame: (data: z.infer<typeof SpectrumFrameSchema>) => void;
1436
1558
  spectrumSessionStateChanged: (data: z.infer<typeof SpectrumSessionStateSchema>) => void;
1437
1559
 
@@ -1455,6 +1577,7 @@ export interface DigitalRadioEngineEvents {
1455
1577
  // 错误和状态事件
1456
1578
  decodeError: (errorInfo: DecodeErrorInfo) => void;
1457
1579
  systemStatus: (status: SystemStatus) => void;
1580
+ bootstrapStatusChanged: (status: BootstrapStatus) => void;
1458
1581
  clientCountChanged: (data: { count: number; timestamp: number }) => void;
1459
1582
  clockStatusChanged: (data: import('./system.schema.js').ClockStatusSummary) => void;
1460
1583
 
@@ -1519,6 +1642,12 @@ export interface DigitalRadioEngineEvents {
1519
1642
  voiceRadioModeChanged: (data: { radioMode: string }) => void;
1520
1643
  voiceKeyerStatusChanged: (data: VoiceKeyerStatus) => void;
1521
1644
 
1645
+ // CW 模式事件
1646
+ cwKeyerStatusChanged: (data: CWKeyerStatus) => void;
1647
+ cwConfigChanged: (data: CWKeyerConfig) => void;
1648
+ cwDecoderStatusChanged: (data: CWDecoderStatus) => void;
1649
+ cwDecoderEvent: (data: CWDecoderEvent) => void;
1650
+
1522
1651
  // 进程监控事件
1523
1652
  processSnapshot: (data: import('./process-monitor.schema.js').ProcessSnapshot) => void;
1524
1653
  processSnapshotHistory: (data: import('./process-monitor.schema.js').ProcessSnapshotHistory) => void;
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ encodeTextToCWEvents,
4
+ encodeTextToCWKeyStateSegments,
5
+ estimateCWMessageDurationMs,
6
+ } from '../cwTiming.js';
7
+
8
+ function totalSegmentDuration(text: string, wpm: number): number {
9
+ return encodeTextToCWKeyStateSegments(text, wpm).reduce((sum, segment) => sum + segment.durationMs, 0);
10
+ }
11
+
12
+ describe('CW timing utilities', () => {
13
+ it('returns no events and zero duration for blank text', () => {
14
+ expect(encodeTextToCWEvents(' ', 20)).toEqual([]);
15
+ expect(encodeTextToCWKeyStateSegments(' ', 20)).toEqual([]);
16
+ expect(estimateCWMessageDurationMs('', 20)).toBe(0);
17
+ });
18
+
19
+ it('encodes exact key events for simple characters and spacing', () => {
20
+ expect(encodeTextToCWEvents('E', 20)).toEqual([
21
+ { type: 'key-down', afterMs: 0 },
22
+ { type: 'key-up', afterMs: 60 },
23
+ ]);
24
+
25
+ expect(encodeTextToCWEvents('EE', 20)).toEqual([
26
+ { type: 'key-down', afterMs: 0 },
27
+ { type: 'key-up', afterMs: 60 },
28
+ { type: 'key-down', afterMs: 180 },
29
+ { type: 'key-up', afterMs: 60 },
30
+ ]);
31
+
32
+ expect(encodeTextToCWEvents('E E', 20)).toEqual([
33
+ { type: 'key-down', afterMs: 0 },
34
+ { type: 'key-up', afterMs: 60 },
35
+ { type: 'key-down', afterMs: 420 },
36
+ { type: 'key-up', afterMs: 60 },
37
+ ]);
38
+
39
+ expect(encodeTextToCWEvents('A', 20)).toEqual([
40
+ { type: 'key-down', afterMs: 0 },
41
+ { type: 'key-up', afterMs: 60 },
42
+ { type: 'key-down', afterMs: 60 },
43
+ { type: 'key-up', afterMs: 180 },
44
+ ]);
45
+ });
46
+
47
+ it('encodes exact key state segments for browser sidetone adapters', () => {
48
+ expect(encodeTextToCWKeyStateSegments('E', 20)).toEqual([
49
+ { keyDown: true, durationMs: 60 },
50
+ ]);
51
+
52
+ expect(encodeTextToCWKeyStateSegments('EE', 20)).toEqual([
53
+ { keyDown: true, durationMs: 60 },
54
+ { keyDown: false, durationMs: 180 },
55
+ { keyDown: true, durationMs: 60 },
56
+ ]);
57
+
58
+ expect(encodeTextToCWKeyStateSegments('E E', 20)).toEqual([
59
+ { keyDown: true, durationMs: 60 },
60
+ { keyDown: false, durationMs: 420 },
61
+ { keyDown: true, durationMs: 60 },
62
+ ]);
63
+
64
+ expect(encodeTextToCWKeyStateSegments('A', 20)).toEqual([
65
+ { keyDown: true, durationMs: 60 },
66
+ { keyDown: false, durationMs: 60 },
67
+ { keyDown: true, durationMs: 180 },
68
+ ]);
69
+ });
70
+
71
+ it('scales duration by WPM', () => {
72
+ expect(estimateCWMessageDurationMs('EE', 20)).toBe(300);
73
+ expect(estimateCWMessageDurationMs('EE', 40)).toBe(150);
74
+ });
75
+
76
+ it('uses the Hamlib/PARIS exact dot duration for non-divisible WPM values', () => {
77
+ expect(estimateCWMessageDurationMs('EE', 17)).toBe(353);
78
+ });
79
+
80
+ it('treats prosigns as single Morse tokens rather than re-encoding dots and dashes', () => {
81
+ expect(estimateCWMessageDurationMs('<SK>', 20)).toBe(900);
82
+ expect(totalSegmentDuration('<SK>', 20)).toBe(900);
83
+ expect(totalSegmentDuration('<CL>', 20)).toBeGreaterThan(0);
84
+ });
85
+
86
+ it('keeps unsupported-character and WPM clamp behavior', () => {
87
+ expect(estimateCWMessageDurationMs('E☃E', 20)).toBe(300);
88
+ expect(estimateCWMessageDurationMs('E', 1)).toBe(240);
89
+ expect(estimateCWMessageDurationMs('E', 1000)).toBe(20);
90
+ });
91
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * CW text to Morse timing encoder.
3
+ *
4
+ * Timing follows the ITU/PARIS convention:
5
+ * - dit = 1200 / WPM ms
6
+ * - dah = 3 dits
7
+ * - intra-character gap = 1 dit
8
+ * - character gap = 3 dits
9
+ * - word gap = 7 dits
10
+ */
11
+ export interface CWTimingEvent {
12
+ type: 'key-down' | 'key-up';
13
+ /** Delay from the previous event, in milliseconds. */
14
+ afterMs: number;
15
+ }
16
+
17
+ export interface CWKeyStateSegment {
18
+ /** true when the key/tone is closed, false for silence/open key. */
19
+ keyDown: boolean;
20
+ /** Segment duration in milliseconds. */
21
+ durationMs: number;
22
+ }
23
+
24
+ const DOT_UNITS = {
25
+ dit: 1,
26
+ dah: 3,
27
+ intraChar: 1,
28
+ interChar: 3,
29
+ interWord: 7,
30
+ } as const;
31
+
32
+ const MORSE_TABLE: Record<string, string> = {
33
+ A: '.-', B: '-...', C: '-.-.', D: '-..',
34
+ E: '.', F: '..-.', G: '--.', H: '....',
35
+ I: '..', J: '.---', K: '-.-', L: '.-..',
36
+ M: '--', N: '-.', O: '---', P: '.--.',
37
+ Q: '--.-', R: '.-.', S: '...', T: '-',
38
+ U: '..-', V: '...-', W: '.--', X: '-..-',
39
+ Y: '-.--', Z: '--..',
40
+ '0': '-----', '1': '.----', '2': '..---', '3': '...--',
41
+ '4': '....-', '5': '.....', '6': '-....', '7': '--...',
42
+ '8': '---..', '9': '----.',
43
+ '.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.',
44
+ '@': '.--.-.', '=': '-...-', '-': '-....-', '+': '.-.-.',
45
+ ':': '---...', ';': '-.-.-.', '\'': '.----.', '"': '.-..-.',
46
+ '!': '-.-.--', '&': '.-...', '(': '-.--.', ')': '-.--.-',
47
+ '_': '..--.-', '$': '...-..-',
48
+ };
49
+
50
+ const PROSIGN_MAP: Record<string, string> = {
51
+ '<AR>': '.-.-.',
52
+ '<SK>': '...-.-',
53
+ '<BT>': '-...-',
54
+ '<KN>': '-.--.',
55
+ '<BK>': '-...-.-',
56
+ '<CL>': '-.-..-..',
57
+ };
58
+
59
+ const PROSIGN_TOKENS = Object.keys(PROSIGN_MAP).sort((a, b) => b.length - a.length);
60
+
61
+ /**
62
+ * Encodes supported CW text into a sequence of timing events.
63
+ */
64
+ export function encodeTextToCWEvents(text: string, wpm: number): CWTimingEvent[] {
65
+ const events: CWTimingEvent[] = [];
66
+ let nextEventDelayMs = 0;
67
+
68
+ for (const segment of encodeTextToCWKeyStateSegments(text, wpm)) {
69
+ if (!segment.keyDown) {
70
+ nextEventDelayMs += segment.durationMs;
71
+ continue;
72
+ }
73
+
74
+ events.push({ type: 'key-down', afterMs: nextEventDelayMs });
75
+ events.push({ type: 'key-up', afterMs: segment.durationMs });
76
+ nextEventDelayMs = 0;
77
+ }
78
+
79
+ return events;
80
+ }
81
+
82
+ /**
83
+ * Encodes supported CW text into key-down/silence duration segments.
84
+ * This is the shared adapter input for browser sidetone and other local keying outputs.
85
+ */
86
+ export function encodeTextToCWKeyStateSegments(text: string, wpm: number): CWKeyStateSegment[] {
87
+ const dotMs = getRoundedDotMs(wpm);
88
+ const tokenizedWords = tokenizeText(text);
89
+ const segments: CWKeyStateSegment[] = [];
90
+ let firstToken = true;
91
+
92
+ for (const word of tokenizedWords) {
93
+ for (let ti = 0; ti < word.length; ti += 1) {
94
+ if (!firstToken) {
95
+ appendSegment(segments, false, (ti === 0 ? DOT_UNITS.interWord : DOT_UNITS.interChar) * dotMs);
96
+ }
97
+
98
+ appendCodeSegments(segments, word[ti], dotMs);
99
+ firstToken = false;
100
+ }
101
+ }
102
+
103
+ return segments;
104
+ }
105
+
106
+ /**
107
+ * Estimates total CW message duration from the first key-down to the final key-up.
108
+ * Uses the PARIS timing base from Hamlib: dot milliseconds = 1200 / WPM.
109
+ */
110
+ export function estimateCWMessageDurationMs(text: string, wpm: number): number {
111
+ const safeWpm = getSafeWpm(wpm);
112
+ const dotUnits = countCWMessageDotUnits(text);
113
+ if (dotUnits <= 0) {
114
+ return 0;
115
+ }
116
+ return Math.ceil(dotUnits * (1200 / safeWpm));
117
+ }
118
+
119
+ function appendCodeSegments(segments: CWKeyStateSegment[], code: string, dotMs: number): void {
120
+ for (let si = 0; si < code.length; si += 1) {
121
+ if (si > 0) {
122
+ appendSegment(segments, false, DOT_UNITS.intraChar * dotMs);
123
+ }
124
+ appendSegment(segments, true, (code[si] === '-' ? DOT_UNITS.dah : DOT_UNITS.dit) * dotMs);
125
+ }
126
+ }
127
+
128
+ function appendSegment(segments: CWKeyStateSegment[], keyDown: boolean, durationMs: number): void {
129
+ if (durationMs <= 0) {
130
+ return;
131
+ }
132
+ const previous = segments[segments.length - 1];
133
+ if (previous?.keyDown === keyDown) {
134
+ previous.durationMs += durationMs;
135
+ return;
136
+ }
137
+ segments.push({ keyDown, durationMs });
138
+ }
139
+
140
+ function countCWMessageDotUnits(text: string): number {
141
+ const tokenizedWords = tokenizeText(text);
142
+ let firstToken = true;
143
+ let totalUnits = 0;
144
+
145
+ for (const word of tokenizedWords) {
146
+ for (let ti = 0; ti < word.length; ti += 1) {
147
+ if (!firstToken) {
148
+ totalUnits += ti === 0 ? DOT_UNITS.interWord : DOT_UNITS.interChar;
149
+ }
150
+
151
+ const code = word[ti];
152
+ for (let si = 0; si < code.length; si += 1) {
153
+ totalUnits += si === 0 ? 0 : DOT_UNITS.intraChar;
154
+ totalUnits += code[si] === '-' ? DOT_UNITS.dah : DOT_UNITS.dit;
155
+ }
156
+ firstToken = false;
157
+ }
158
+ }
159
+
160
+ return totalUnits;
161
+ }
162
+
163
+ function tokenizeText(text: string): string[][] {
164
+ const normalized = text.toUpperCase().trim();
165
+ if (!normalized) {
166
+ return [];
167
+ }
168
+
169
+ return normalized
170
+ .split(/\s+/)
171
+ .map(tokenizeWord)
172
+ .filter((word) => word.length > 0);
173
+ }
174
+
175
+ function tokenizeWord(word: string): string[] {
176
+ const codes: string[] = [];
177
+ let index = 0;
178
+
179
+ while (index < word.length) {
180
+ const prosign = PROSIGN_TOKENS.find((token) => word.startsWith(token, index));
181
+ if (prosign) {
182
+ codes.push(PROSIGN_MAP[prosign]);
183
+ index += prosign.length;
184
+ continue;
185
+ }
186
+
187
+ const code = MORSE_TABLE[word[index]];
188
+ if (code) {
189
+ codes.push(code);
190
+ }
191
+ index += 1;
192
+ }
193
+
194
+ return codes;
195
+ }
196
+
197
+ function getRoundedDotMs(wpm: number): number {
198
+ return Math.round(1200 / getSafeWpm(wpm));
199
+ }
200
+
201
+ function getSafeWpm(wpm: number): number {
202
+ return Math.max(5, Math.min(60, Math.round(Number(wpm) || 20)));
203
+ }
@@ -8,6 +8,8 @@ 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-right-top')).toBe('cw-right-top');
12
+ expect(PluginPanelSlotSchema.parse('radio-control-toolbar')).toBe('radio-control-toolbar');
11
13
  });
12
14
 
13
15
  it('validates iframe panels declared in the new host slots', () => {
@@ -27,4 +29,54 @@ describe('PluginPanelSlotSchema', () => {
27
29
  slot: 'voice-right-top',
28
30
  })).not.toThrow();
29
31
  });
32
+
33
+ it('validates radio control toolbar iframe panel metadata', () => {
34
+ expect(PluginPanelDescriptorSchema.parse({
35
+ id: 'rotator-button',
36
+ title: 'rotatorTitle',
37
+ component: 'iframe',
38
+ pageId: 'rotator',
39
+ slot: 'radio-control-toolbar',
40
+ icon: 'brands:github',
41
+ openMode: 'modal',
42
+ uiSize: 'lg',
43
+ })).toMatchObject({
44
+ slot: 'radio-control-toolbar',
45
+ icon: 'brands:github',
46
+ openMode: 'modal',
47
+ uiSize: 'lg',
48
+ });
49
+
50
+ expect(() => PluginPanelDescriptorSchema.parse({
51
+ id: 'bad-toolbar',
52
+ title: 'Bad',
53
+ component: 'key-value',
54
+ slot: 'radio-control-toolbar',
55
+ })).toThrow('radio-control-toolbar panels must use iframe component');
56
+
57
+ expect(() => PluginPanelDescriptorSchema.parse({
58
+ id: 'missing-page',
59
+ title: 'Missing',
60
+ component: 'iframe',
61
+ slot: 'radio-control-toolbar',
62
+ })).toThrow('radio-control-toolbar panels must declare pageId');
63
+
64
+ expect(() => PluginPanelDescriptorSchema.parse({
65
+ id: 'bad-open-mode',
66
+ title: 'Bad',
67
+ component: 'iframe',
68
+ pageId: 'rotator',
69
+ slot: 'radio-control-toolbar',
70
+ openMode: 'drawer',
71
+ })).toThrow();
72
+
73
+ expect(() => PluginPanelDescriptorSchema.parse({
74
+ id: 'bad-size',
75
+ title: 'Bad',
76
+ component: 'iframe',
77
+ pageId: 'rotator',
78
+ slot: 'radio-control-toolbar',
79
+ uiSize: 'xl',
80
+ })).toThrow();
81
+ });
30
82
  });