@tx5dr/contracts 1.7.11 → 2.0.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.
Files changed (200) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/schema/__tests__/android-operator-audio.schema.test.d.ts +2 -0
  6. package/dist/schema/__tests__/android-operator-audio.schema.test.d.ts.map +1 -0
  7. package/dist/schema/__tests__/android-operator-audio.schema.test.js +27 -0
  8. package/dist/schema/__tests__/android-operator-audio.schema.test.js.map +1 -0
  9. package/dist/schema/__tests__/assisted-queue.schema.test.d.ts +2 -0
  10. package/dist/schema/__tests__/assisted-queue.schema.test.d.ts.map +1 -0
  11. package/dist/schema/__tests__/assisted-queue.schema.test.js +113 -0
  12. package/dist/schema/__tests__/assisted-queue.schema.test.js.map +1 -0
  13. package/dist/schema/__tests__/audio-sidecar.schema.test.d.ts +2 -0
  14. package/dist/schema/__tests__/audio-sidecar.schema.test.d.ts.map +1 -0
  15. package/dist/schema/__tests__/audio-sidecar.schema.test.js +42 -0
  16. package/dist/schema/__tests__/audio-sidecar.schema.test.js.map +1 -0
  17. package/dist/schema/__tests__/audio.schema.test.js +92 -1
  18. package/dist/schema/__tests__/audio.schema.test.js.map +1 -1
  19. package/dist/schema/__tests__/diagnostics.schema.test.d.ts +2 -0
  20. package/dist/schema/__tests__/diagnostics.schema.test.d.ts.map +1 -0
  21. package/dist/schema/__tests__/diagnostics.schema.test.js +31 -0
  22. package/dist/schema/__tests__/diagnostics.schema.test.js.map +1 -0
  23. package/dist/schema/__tests__/logbook-health.schema.test.d.ts +2 -0
  24. package/dist/schema/__tests__/logbook-health.schema.test.d.ts.map +1 -0
  25. package/dist/schema/__tests__/logbook-health.schema.test.js +104 -0
  26. package/dist/schema/__tests__/logbook-health.schema.test.js.map +1 -0
  27. package/dist/schema/__tests__/observability.schema.test.d.ts +2 -0
  28. package/dist/schema/__tests__/observability.schema.test.d.ts.map +1 -0
  29. package/dist/schema/__tests__/observability.schema.test.js +12 -0
  30. package/dist/schema/__tests__/observability.schema.test.js.map +1 -0
  31. package/dist/schema/__tests__/operator.schema.test.js +9 -3
  32. package/dist/schema/__tests__/operator.schema.test.js.map +1 -1
  33. package/dist/schema/__tests__/plugin-market.schema.test.js +43 -1
  34. package/dist/schema/__tests__/plugin-market.schema.test.js.map +1 -1
  35. package/dist/schema/__tests__/radio-power-support.test.js +12 -0
  36. package/dist/schema/__tests__/radio-power-support.test.js.map +1 -1
  37. package/dist/schema/__tests__/radio-profile.schema.test.d.ts +2 -0
  38. package/dist/schema/__tests__/radio-profile.schema.test.d.ts.map +1 -0
  39. package/dist/schema/__tests__/radio-profile.schema.test.js +36 -0
  40. package/dist/schema/__tests__/radio-profile.schema.test.js.map +1 -0
  41. package/dist/schema/__tests__/radio.schema.test.js +41 -1
  42. package/dist/schema/__tests__/radio.schema.test.js.map +1 -1
  43. package/dist/schema/__tests__/realtime-settings.schema.test.js +2 -1
  44. package/dist/schema/__tests__/realtime-settings.schema.test.js.map +1 -1
  45. package/dist/schema/__tests__/remote-access.schema.test.d.ts +2 -0
  46. package/dist/schema/__tests__/remote-access.schema.test.d.ts.map +1 -0
  47. package/dist/schema/__tests__/remote-access.schema.test.js +18 -0
  48. package/dist/schema/__tests__/remote-access.schema.test.js.map +1 -0
  49. package/dist/schema/android-operator-audio.schema.d.ts +174 -0
  50. package/dist/schema/android-operator-audio.schema.d.ts.map +1 -0
  51. package/dist/schema/android-operator-audio.schema.js +38 -0
  52. package/dist/schema/android-operator-audio.schema.js.map +1 -0
  53. package/dist/schema/api-response.schema.d.ts +268 -44
  54. package/dist/schema/api-response.schema.d.ts.map +1 -1
  55. package/dist/schema/audio-sidecar.schema.d.ts +75 -0
  56. package/dist/schema/audio-sidecar.schema.d.ts.map +1 -1
  57. package/dist/schema/audio-sidecar.schema.js +32 -0
  58. package/dist/schema/audio-sidecar.schema.js.map +1 -1
  59. package/dist/schema/audio.schema.d.ts +4749 -3
  60. package/dist/schema/audio.schema.d.ts.map +1 -1
  61. package/dist/schema/audio.schema.js +101 -2
  62. package/dist/schema/audio.schema.js.map +1 -1
  63. package/dist/schema/auth.schema.d.ts +171 -12
  64. package/dist/schema/auth.schema.d.ts.map +1 -1
  65. package/dist/schema/auth.schema.js +65 -1
  66. package/dist/schema/auth.schema.js.map +1 -1
  67. package/dist/schema/cpu-profile.schema.d.ts +2 -2
  68. package/dist/schema/cw-decoder.schema.d.ts +74 -74
  69. package/dist/schema/cw-keyer.schema.d.ts +20 -14
  70. package/dist/schema/cw-keyer.schema.d.ts.map +1 -1
  71. package/dist/schema/cw-keyer.schema.js +3 -0
  72. package/dist/schema/cw-keyer.schema.js.map +1 -1
  73. package/dist/schema/desktop-https.schema.d.ts +6 -6
  74. package/dist/schema/device-ui.schema.d.ts +6 -6
  75. package/dist/schema/diagnostics.schema.d.ts +163 -0
  76. package/dist/schema/diagnostics.schema.d.ts.map +1 -0
  77. package/dist/schema/diagnostics.schema.js +46 -0
  78. package/dist/schema/diagnostics.schema.js.map +1 -0
  79. package/dist/schema/ft8.schema.d.ts +12 -2
  80. package/dist/schema/ft8.schema.d.ts.map +1 -1
  81. package/dist/schema/ft8.schema.js +8 -0
  82. package/dist/schema/ft8.schema.js.map +1 -1
  83. package/dist/schema/logbook.schema.d.ts +1940 -18
  84. package/dist/schema/logbook.schema.d.ts.map +1 -1
  85. package/dist/schema/logbook.schema.js +158 -1
  86. package/dist/schema/logbook.schema.js.map +1 -1
  87. package/dist/schema/mode.schema.d.ts +2 -6
  88. package/dist/schema/mode.schema.d.ts.map +1 -1
  89. package/dist/schema/mode.schema.js +2 -6
  90. package/dist/schema/mode.schema.js.map +1 -1
  91. package/dist/schema/observability.schema.d.ts +66 -0
  92. package/dist/schema/observability.schema.d.ts.map +1 -0
  93. package/dist/schema/observability.schema.js +20 -0
  94. package/dist/schema/observability.schema.js.map +1 -0
  95. package/dist/schema/operator.schema.d.ts +6 -6
  96. package/dist/schema/operator.schema.d.ts.map +1 -1
  97. package/dist/schema/operator.schema.js +6 -1
  98. package/dist/schema/operator.schema.js.map +1 -1
  99. package/dist/schema/plugin.schema.d.ts +258 -78
  100. package/dist/schema/plugin.schema.d.ts.map +1 -1
  101. package/dist/schema/plugin.schema.js +57 -2
  102. package/dist/schema/plugin.schema.js.map +1 -1
  103. package/dist/schema/process-monitor.schema.d.ts +132 -132
  104. package/dist/schema/pskreporter.schema.d.ts +1 -3
  105. package/dist/schema/pskreporter.schema.d.ts.map +1 -1
  106. package/dist/schema/pskreporter.schema.js +1 -3
  107. package/dist/schema/pskreporter.schema.js.map +1 -1
  108. package/dist/schema/qso.schema.d.ts +7 -0
  109. package/dist/schema/qso.schema.d.ts.map +1 -1
  110. package/dist/schema/qso.schema.js +7 -0
  111. package/dist/schema/qso.schema.js.map +1 -1
  112. package/dist/schema/radio-capability.schema.d.ts +31 -40
  113. package/dist/schema/radio-capability.schema.d.ts.map +1 -1
  114. package/dist/schema/radio-capability.schema.js +7 -15
  115. package/dist/schema/radio-capability.schema.js.map +1 -1
  116. package/dist/schema/radio-power-support.d.ts.map +1 -1
  117. package/dist/schema/radio-power-support.js +3 -0
  118. package/dist/schema/radio-power-support.js.map +1 -1
  119. package/dist/schema/radio-power.schema.d.ts +87 -13
  120. package/dist/schema/radio-power.schema.d.ts.map +1 -1
  121. package/dist/schema/radio-power.schema.js +15 -8
  122. package/dist/schema/radio-power.schema.js.map +1 -1
  123. package/dist/schema/radio-profile.schema.d.ts +1240 -97
  124. package/dist/schema/radio-profile.schema.d.ts.map +1 -1
  125. package/dist/schema/radio.schema.d.ts +271 -48
  126. package/dist/schema/radio.schema.d.ts.map +1 -1
  127. package/dist/schema/radio.schema.js +44 -5
  128. package/dist/schema/radio.schema.js.map +1 -1
  129. package/dist/schema/realtime.schema.d.ts +60 -52
  130. package/dist/schema/realtime.schema.d.ts.map +1 -1
  131. package/dist/schema/realtime.schema.js +9 -1
  132. package/dist/schema/realtime.schema.js.map +1 -1
  133. package/dist/schema/slot-info.schema.d.ts +15 -9
  134. package/dist/schema/slot-info.schema.d.ts.map +1 -1
  135. package/dist/schema/slot-info.schema.js +15 -9
  136. package/dist/schema/slot-info.schema.js.map +1 -1
  137. package/dist/schema/spectrum.schema.d.ts +12 -12
  138. package/dist/schema/station-info.schema.d.ts +5 -0
  139. package/dist/schema/station-info.schema.d.ts.map +1 -1
  140. package/dist/schema/station-info.schema.js +5 -0
  141. package/dist/schema/station-info.schema.js.map +1 -1
  142. package/dist/schema/system.schema.d.ts +56 -6
  143. package/dist/schema/system.schema.d.ts.map +1 -1
  144. package/dist/schema/system.schema.js +22 -0
  145. package/dist/schema/system.schema.js.map +1 -1
  146. package/dist/schema/transmission.schema.d.ts +1 -7
  147. package/dist/schema/transmission.schema.d.ts.map +1 -1
  148. package/dist/schema/transmission.schema.js +2 -7
  149. package/dist/schema/transmission.schema.js.map +1 -1
  150. package/dist/schema/update.schema.d.ts +3 -0
  151. package/dist/schema/update.schema.d.ts.map +1 -1
  152. package/dist/schema/update.schema.js +1 -0
  153. package/dist/schema/update.schema.js.map +1 -1
  154. package/dist/schema/voice-keyer.schema.d.ts +16 -16
  155. package/dist/schema/websocket.schema.d.ts +3582 -555
  156. package/dist/schema/websocket.schema.d.ts.map +1 -1
  157. package/dist/schema/websocket.schema.js +160 -3
  158. package/dist/schema/websocket.schema.js.map +1 -1
  159. package/package.json +6 -1
  160. package/src/index.ts +3 -0
  161. package/src/schema/__tests__/android-operator-audio.schema.test.ts +31 -0
  162. package/src/schema/__tests__/assisted-queue.schema.test.ts +121 -0
  163. package/src/schema/__tests__/audio-sidecar.schema.test.ts +48 -0
  164. package/src/schema/__tests__/audio.schema.test.ts +102 -0
  165. package/src/schema/__tests__/diagnostics.schema.test.ts +32 -0
  166. package/src/schema/__tests__/logbook-health.schema.test.ts +117 -0
  167. package/src/schema/__tests__/observability.schema.test.ts +15 -0
  168. package/src/schema/__tests__/operator.schema.test.ts +11 -3
  169. package/src/schema/__tests__/plugin-market.schema.test.ts +49 -0
  170. package/src/schema/__tests__/radio-power-support.test.ts +14 -0
  171. package/src/schema/__tests__/radio-profile.schema.test.ts +44 -0
  172. package/src/schema/__tests__/radio.schema.test.ts +49 -1
  173. package/src/schema/__tests__/realtime-settings.schema.test.ts +2 -1
  174. package/src/schema/__tests__/remote-access.schema.test.ts +22 -0
  175. package/src/schema/android-operator-audio.schema.ts +49 -0
  176. package/src/schema/audio-sidecar.schema.ts +35 -0
  177. package/src/schema/audio.schema.ts +115 -2
  178. package/src/schema/auth.schema.ts +79 -1
  179. package/src/schema/cw-keyer.schema.ts +5 -0
  180. package/src/schema/diagnostics.schema.ts +55 -0
  181. package/src/schema/ft8.schema.ts +8 -0
  182. package/src/schema/logbook.schema.ts +204 -1
  183. package/src/schema/mode.schema.ts +2 -6
  184. package/src/schema/observability.schema.ts +26 -0
  185. package/src/schema/operator.schema.ts +7 -1
  186. package/src/schema/plugin.schema.ts +67 -2
  187. package/src/schema/pskreporter.schema.ts +1 -3
  188. package/src/schema/qso.schema.ts +7 -0
  189. package/src/schema/radio-capability.schema.ts +7 -15
  190. package/src/schema/radio-power-support.ts +3 -0
  191. package/src/schema/radio-power.schema.ts +15 -8
  192. package/src/schema/radio.schema.ts +55 -5
  193. package/src/schema/realtime.schema.ts +9 -1
  194. package/src/schema/slot-info.schema.ts +16 -10
  195. package/src/schema/station-info.schema.ts +5 -0
  196. package/src/schema/system.schema.ts +29 -0
  197. package/src/schema/transmission.schema.ts +2 -7
  198. package/src/schema/update.schema.ts +1 -0
  199. package/src/schema/websocket.schema.ts +223 -6
  200. package/test/radio-capability-schema.test.ts +44 -0
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ CreateLogbookBackupRequestSchema,
5
+ LogBookInfoSchema,
6
+ LogbookHealthSchema,
7
+ RestoreLogbookRequestSchema,
8
+ WSMessageSchema,
9
+ WSMessageType,
10
+ } from '../../index.js';
11
+
12
+ const loadingHealth = {
13
+ state: 'loading' as const,
14
+ readable: false,
15
+ writable: false,
16
+ issues: [],
17
+ updatedAt: 1_775_394_000_000,
18
+ };
19
+
20
+ describe('logbook health contracts', () => {
21
+ it('requires every logbook listing to expose health', () => {
22
+ const info = {
23
+ id: 'logbook-N0CALL',
24
+ name: 'N0CALL QSO Log',
25
+ fileName: 'N0CALL.adi',
26
+ storageKind: 'managed' as const,
27
+ createdAt: 1,
28
+ lastUsed: 2,
29
+ isActive: true,
30
+ };
31
+
32
+ expect(LogBookInfoSchema.safeParse(info).success).toBe(false);
33
+ const parsed = LogBookInfoSchema.parse({ ...info, health: loadingHealth });
34
+ expect(parsed.health).toEqual(loadingHealth);
35
+ expect(parsed).not.toHaveProperty('filePath');
36
+ });
37
+
38
+ it('rejects path-bearing fields in backup and restore mutations', () => {
39
+ expect(CreateLogbookBackupRequestSchema.safeParse({
40
+ filePath: '/tmp/escape.adi',
41
+ }).success).toBe(false);
42
+ expect(RestoreLogbookRequestSchema.safeParse({
43
+ preflightToken: 'preflight-token-1',
44
+ confirmation: 'logbook-N0CALL',
45
+ artifactPath: '../another.adi',
46
+ }).success).toBe(false);
47
+ });
48
+
49
+ it('rejects negative affected record and byte counts', () => {
50
+ expect(LogbookHealthSchema.safeParse({
51
+ ...loadingHealth,
52
+ issues: [{
53
+ code: 'OPAQUE_RECORDS',
54
+ message: 'One record was skipped',
55
+ affectedRecords: -1,
56
+ affectedBytes: -1,
57
+ occurredAt: loadingHealth.updatedAt,
58
+ }],
59
+ }).success).toBe(false);
60
+ });
61
+
62
+ it('validates health and write-failure websocket payloads', () => {
63
+ expect(WSMessageSchema.safeParse({
64
+ type: WSMessageType.LOGBOOK_HEALTH_CHANGED,
65
+ timestamp: new Date().toISOString(),
66
+ data: { logBookId: 'logbook-N0CALL', health: loadingHealth },
67
+ }).success).toBe(true);
68
+ expect(WSMessageSchema.safeParse({
69
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
70
+ timestamp: new Date().toISOString(),
71
+ data: {
72
+ logBookId: 'logbook-N0CALL',
73
+ operatorId: 'operator-1',
74
+ attemptId: 'attempt-12345678',
75
+ unsavedCount: 1,
76
+ error: {
77
+ code: 'LOGBOOK_WRITE_FAILED',
78
+ message: 'No space left on device',
79
+ systemCode: 'ENOSPC',
80
+ occurredAt: loadingHealth.updatedAt,
81
+ },
82
+ },
83
+ }).success).toBe(true);
84
+ expect(WSMessageSchema.safeParse({
85
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
86
+ timestamp: new Date().toISOString(),
87
+ data: {
88
+ logBookId: 'logbook-N0CALL',
89
+ qsoRecord: {
90
+ id: 'private-qso',
91
+ callsign: 'N0CALL',
92
+ frequency: 14_074_000,
93
+ mode: 'FT8',
94
+ startTime: loadingHealth.updatedAt,
95
+ messageHistory: ['CQ N0CALL AA00'],
96
+ },
97
+ error: {
98
+ code: 'LOGBOOK_WRITE_FAILED',
99
+ message: 'write failed',
100
+ occurredAt: loadingHealth.updatedAt,
101
+ },
102
+ },
103
+ }).success).toBe(false);
104
+ expect(WSMessageSchema.safeParse({
105
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
106
+ timestamp: new Date().toISOString(),
107
+ data: {
108
+ logBookId: 'logbook-N0CALL',
109
+ error: {
110
+ code: 'ARBITRARY_ERROR',
111
+ message: 'Unstable error code',
112
+ occurredAt: loadingHealth.updatedAt,
113
+ },
114
+ },
115
+ }).success).toBe(false);
116
+ });
117
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ OBSERVABILITY_NOTICE_VERSION,
4
+ UpdateObservabilitySettingsSchema,
5
+ } from '../observability.schema.js';
6
+
7
+ describe('observability settings schema', () => {
8
+ it('requires acknowledgment of the current notice', () => {
9
+ expect(UpdateObservabilitySettingsSchema.parse({
10
+ enabled: true,
11
+ noticeVersion: OBSERVABILITY_NOTICE_VERSION,
12
+ })).toEqual({ enabled: true, noticeVersion: 1 });
13
+ expect(() => UpdateObservabilitySettingsSchema.parse({ enabled: true, noticeVersion: 0 })).toThrow();
14
+ });
15
+ });
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  CreateRadioOperatorRequestSchema,
4
4
  UpdateRadioOperatorRequestSchema,
5
- sanitizeCallsignInput
5
+ sanitizeCallsignInput,
6
+ sanitizeGridInput,
6
7
  } from '../../index.js';
7
8
  import { MODES } from '../mode.schema.js';
8
9
 
@@ -11,19 +12,26 @@ describe('operator callsign normalization', () => {
11
12
  expect(sanitizeCallsignInput(' bg5abc/p ')).toBe('BG5ABC/P');
12
13
  });
13
14
 
14
- it('normalizes callsigns in create and update payloads', () => {
15
+ it('uppercases grids and removes whitespace', () => {
16
+ expect(sanitizeGridInput(' ol 63aa ')).toBe('OL63AA');
17
+ });
18
+
19
+ it('normalizes callsigns and grids in create and update payloads', () => {
15
20
  const createPayload = CreateRadioOperatorRequestSchema.parse({
16
21
  myCallsign: 'bg5abc/p',
17
- myGrid: 'OL63',
22
+ myGrid: 'ol63aa',
18
23
  frequency: 1000,
19
24
  transmitCycles: [0],
20
25
  mode: MODES.FT8,
21
26
  });
22
27
  const updatePayload = UpdateRadioOperatorRequestSchema.parse({
23
28
  myCallsign: 'vk9/bg3yza',
29
+ myGrid: 'pm01',
24
30
  });
25
31
 
26
32
  expect(createPayload.myCallsign).toBe('BG5ABC/P');
33
+ expect(createPayload.myGrid).toBe('OL63AA');
27
34
  expect(updatePayload.myCallsign).toBe('VK9/BG3YZA');
35
+ expect(updatePayload.myGrid).toBe('PM01');
28
36
  });
29
37
  });
@@ -3,8 +3,11 @@ import {
3
3
  PluginMarketCatalogResponseSchema,
4
4
  PluginMarketCatalogSchema,
5
5
  PluginMarketChannelSchema,
6
+ PluginManifestSchema,
6
7
  PluginPermissionSchema,
8
+ PluginsConfigSchema,
7
9
  PluginSourceSchema,
10
+ PluginStatusSchema,
8
11
  } from '../../index.js';
9
12
 
10
13
  describe('plugin market schema', () => {
@@ -18,6 +21,8 @@ describe('plugin market schema', () => {
18
21
  name: 'heartbeat-demo',
19
22
  title: 'Heartbeat Demo',
20
23
  description: 'Example timer and quick-action plugin.',
24
+ readmeMarkdown: '# Heartbeat Demo\n\nExample timer and quick-action plugin.',
25
+ readmeSourceUrl: 'https://github.com/boybook/tx-5dr-plugins/blob/main/heartbeat-demo/README.md',
21
26
  locales: {
22
27
  en: {
23
28
  pluginName: 'Heartbeat Demo',
@@ -53,6 +58,8 @@ describe('plugin market schema', () => {
53
58
 
54
59
  expect(catalog.channel).toBe('stable');
55
60
  expect(catalog.plugins[0]?.name).toBe('heartbeat-demo');
61
+ expect(catalog.plugins[0]?.readmeMarkdown).toContain('# Heartbeat Demo');
62
+ expect(catalog.plugins[0]?.readmeSourceUrl).toContain('/heartbeat-demo/README.md');
56
63
  });
57
64
 
58
65
  it('rejects unsupported channels', () => {
@@ -60,9 +67,51 @@ describe('plugin market schema', () => {
60
67
  });
61
68
 
62
69
  it('accepts host settings permissions', () => {
70
+ expect(PluginPermissionSchema.parse('plugin:event-bus')).toBe('plugin:event-bus');
63
71
  expect(PluginPermissionSchema.parse('host:hamlib')).toBe('host:hamlib');
72
+ expect(PluginPermissionSchema.parse('operator:transmit-control')).toBe('operator:transmit-control');
64
73
  expect(PluginPermissionSchema.parse('settings:ft8')).toBe('settings:ft8');
65
74
  expect(PluginPermissionSchema.parse('settings:ntp')).toBe('settings:ntp');
75
+ expect(() => PluginPermissionSchema.parse('operator:transmit')).toThrow();
76
+ });
77
+
78
+ it('accepts transmit-control permission and auto-call status in plugin snapshots', () => {
79
+ const manifest = PluginManifestSchema.parse({
80
+ name: 'autocall-demo',
81
+ version: '1.0.0',
82
+ type: 'utility',
83
+ permissions: ['operator:transmit-control'],
84
+ });
85
+ expect(manifest.permissions).toEqual(['operator:transmit-control']);
86
+
87
+ const status = PluginStatusSchema.parse({
88
+ name: 'autocall-demo',
89
+ version: '1.0.0',
90
+ type: 'utility',
91
+ isBuiltIn: false,
92
+ loaded: true,
93
+ enabled: true,
94
+ errorCount: 0,
95
+ permissions: ['operator:transmit-control'],
96
+ capabilities: ['auto_call_control'],
97
+ autoCallEnabledOperatorIds: ['operator-1'],
98
+ pausedOperatorIds: ['operator-2'],
99
+ });
100
+ expect(status.autoCallEnabledOperatorIds).toEqual(['operator-1']);
101
+ expect(status.pausedOperatorIds).toEqual(['operator-2']);
102
+ expect(status.capabilities).toContain('auto_call_control');
103
+ });
104
+
105
+ it('accepts persisted operator plugin pause state with defaults', () => {
106
+ const emptyConfig = PluginsConfigSchema.parse({});
107
+ expect(emptyConfig.operatorPluginPauses).toEqual({});
108
+
109
+ const config = PluginsConfigSchema.parse({
110
+ operatorPluginPauses: {
111
+ 'operator-1': ['scheduled-cq-autocall'],
112
+ },
113
+ });
114
+ expect(config.operatorPluginPauses['operator-1']).toEqual(['scheduled-cq-autocall']);
66
115
  });
67
116
 
68
117
  it('accepts the route response envelope', () => {
@@ -23,4 +23,18 @@ describe('radio power support', () => {
23
23
  }),
24
24
  ).not.toThrow();
25
25
  });
26
+
27
+ it('does not expose physical power controls for TCI radios', () => {
28
+ const decision = decidePowerSupport({
29
+ type: 'tci',
30
+ tci: { host: '127.0.0.1', port: 40001, receiver: 0, trx: 0, vfo: 0, audioEnabled: true, audioSampleRate: 12000 },
31
+ });
32
+
33
+ expect(decision).toMatchObject({
34
+ canPowerOn: false,
35
+ canPowerOff: false,
36
+ supportedStates: [],
37
+ reason: 'model-unsupported',
38
+ });
39
+ });
26
40
  });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CreateProfileRequestSchema,
4
+ RadioProfileSchema,
5
+ UpdateProfileRequestSchema,
6
+ } from '../radio-profile.schema.js';
7
+
8
+ const audio = {
9
+ inputDeviceName: 'Headset microphone',
10
+ inputRouteKey: 'android:wired-headset:input',
11
+ outputDeviceName: 'Headset headphones',
12
+ outputRouteKey: 'android:wired-headset:output',
13
+ inputSampleRate: 48000,
14
+ outputSampleRate: 48000,
15
+ };
16
+
17
+ describe('radio profile audio route identity', () => {
18
+ it('preserves stable route keys through create and update requests', () => {
19
+ expect(CreateProfileRequestSchema.parse({
20
+ name: 'Android 3.5 mm',
21
+ radio: { type: 'none' },
22
+ audio,
23
+ }).audio).toMatchObject(audio);
24
+
25
+ expect(UpdateProfileRequestSchema.parse({ audio }).audio).toMatchObject(audio);
26
+ });
27
+
28
+ it('loads stable route keys while accepting legacy name-only profiles', () => {
29
+ const baseProfile = {
30
+ id: 'profile-1',
31
+ name: 'Android 3.5 mm',
32
+ radio: { type: 'none' as const },
33
+ audioLockedToRadio: false,
34
+ createdAt: 1,
35
+ updatedAt: 2,
36
+ };
37
+
38
+ expect(RadioProfileSchema.parse({ ...baseProfile, audio }).audio).toMatchObject(audio);
39
+ expect(RadioProfileSchema.parse({
40
+ ...baseProfile,
41
+ audio: { inputDeviceName: 'Legacy USB Audio' },
42
+ }).audio).toEqual({ inputDeviceName: 'Legacy USB Audio' });
43
+ });
44
+ });
@@ -1,5 +1,53 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { PresetFrequencySchema } from '../radio.schema.js';
2
+ import { DigitalModeRadioModePreferenceSchema, HamlibConfigSchema, PresetFrequencySchema, RadioInfoSchema } from '../radio.schema.js';
3
+
4
+ describe('HamlibConfigSchema digital mode radio mode preference', () => {
5
+ it('accepts legacy configs without a digital mode radio mode preference', () => {
6
+ const parsed = HamlibConfigSchema.parse({ type: 'none' });
7
+
8
+ expect(parsed.digitalModeRadioMode).toBeUndefined();
9
+ });
10
+
11
+ it('accepts supported FT8/FT4 radio mode preferences', () => {
12
+ expect(DigitalModeRadioModePreferenceSchema.parse('none')).toBe('none');
13
+ expect(DigitalModeRadioModePreferenceSchema.parse('usb')).toBe('usb');
14
+ expect(DigitalModeRadioModePreferenceSchema.parse('usb-data')).toBe('usb-data');
15
+ expect(HamlibConfigSchema.parse({ type: 'serial', digitalModeRadioMode: 'usb-data' }).digitalModeRadioMode).toBe('usb-data');
16
+ });
17
+
18
+ it('rejects invalid FT8/FT4 radio mode preferences', () => {
19
+ expect(() => HamlibConfigSchema.parse({ type: 'none', digitalModeRadioMode: 'lsb-data' })).toThrow();
20
+ });
21
+ });
22
+
23
+ describe('HamlibConfigSchema TCI connection', () => {
24
+ it('accepts TCI configs and applies SunSDR defaults', () => {
25
+ const parsed = HamlibConfigSchema.parse({
26
+ type: 'tci',
27
+ tci: {},
28
+ });
29
+
30
+ expect(parsed.tci).toMatchObject({
31
+ host: '127.0.0.1',
32
+ port: 40001,
33
+ receiver: 0,
34
+ trx: 0,
35
+ vfo: 0,
36
+ audioEnabled: true,
37
+ audioSampleRate: 12000,
38
+ });
39
+ });
40
+
41
+ it('accepts TCI as a reported radio connection type', () => {
42
+ const parsed = RadioInfoSchema.parse({
43
+ manufacturer: 'Expert Electronics',
44
+ model: 'SunSDR',
45
+ connectionType: 'tci',
46
+ });
47
+
48
+ expect(parsed.connectionType).toBe('tci');
49
+ });
50
+ });
3
51
 
4
52
  describe('PresetFrequencySchema repeater DUP fields', () => {
5
53
  it('accepts existing presets without repeater DUP fields', () => {
@@ -9,9 +9,10 @@ import {
9
9
  } from '../realtime.schema.js';
10
10
 
11
11
  describe('Realtime transport schemas', () => {
12
- it('accepts only rtc-data-audio and ws-compat transports', () => {
12
+ it('accepts realtime network transports plus the Android native diagnostics transport', () => {
13
13
  expect(RealtimeTransportKindSchema.parse('rtc-data-audio')).toBe('rtc-data-audio');
14
14
  expect(RealtimeTransportKindSchema.parse('ws-compat')).toBe('ws-compat');
15
+ expect(RealtimeTransportKindSchema.parse('android-native')).toBe('android-native');
15
16
  const retiredTransport = 'live' + 'kit';
16
17
  expect(() => RealtimeTransportKindSchema.parse(retiredTransport)).toThrow();
17
18
  expect(() => RealtimeSessionRequestSchema.parse({
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ isSecureRemoteAccessOrigin,
4
+ normalizeRemoteAccessOrigin,
5
+ } from '../auth.schema.js';
6
+
7
+ describe('remote access origins', () => {
8
+ it('normalizes exact browser origins and rejects paths or credentials', () => {
9
+ expect(normalizeRemoteAccessOrigin('https://radio.example.com/')).toBe('https://radio.example.com');
10
+ expect(normalizeRemoteAccessOrigin('http://10.8.0.2:8076')).toBe('http://10.8.0.2:8076');
11
+ expect(normalizeRemoteAccessOrigin('https://radio.example.com/tx5dr')).toBeNull();
12
+ expect(normalizeRemoteAccessOrigin('https://user:pass@radio.example.com')).toBeNull();
13
+ });
14
+
15
+ it('requires HTTPS for public hosts while allowing private-network HTTP', () => {
16
+ expect(isSecureRemoteAccessOrigin('https://radio.example.com')).toBe(true);
17
+ expect(isSecureRemoteAccessOrigin('http://radio.example.com')).toBe(false);
18
+ expect(isSecureRemoteAccessOrigin('http://203.0.113.10:8076')).toBe(false);
19
+ expect(isSecureRemoteAccessOrigin('http://192.168.1.20:8076')).toBe(true);
20
+ expect(isSecureRemoteAccessOrigin('http://100.64.0.2:8076')).toBe(true);
21
+ });
22
+ });
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+
3
+ export const AndroidOperatorAudioDeviceSchema = z.object({
4
+ id: z.string(),
5
+ name: z.string(),
6
+ kind: z.string(),
7
+ socketPath: z.string(),
8
+ sampleRate: z.number().int().positive(),
9
+ connected: z.boolean().optional(),
10
+ });
11
+ export type AndroidOperatorAudioDevice = z.infer<typeof AndroidOperatorAudioDeviceSchema>;
12
+
13
+ export const AndroidOperatorAudioCaptureStateSchema = z.enum(['idle', 'preparing', 'capturing', 'error']);
14
+ export type AndroidOperatorAudioCaptureState = z.infer<typeof AndroidOperatorAudioCaptureStateSchema>;
15
+
16
+ export const AndroidOperatorAudioMonitorStateSchema = z.enum(['idle', 'starting', 'playing', 'paused-for-ptt', 'error']);
17
+ export type AndroidOperatorAudioMonitorState = z.infer<typeof AndroidOperatorAudioMonitorStateSchema>;
18
+
19
+ export const AndroidOperatorAudioStatusSchema = z.object({
20
+ available: z.boolean(),
21
+ captureState: AndroidOperatorAudioCaptureStateSchema,
22
+ monitorState: AndroidOperatorAudioMonitorStateSchema,
23
+ participantIdentity: z.string().nullable(),
24
+ inputLevel: z.number().min(0).max(1),
25
+ inputPeak: z.number().min(0).max(1),
26
+ rawInputLevel: z.number().min(0).max(1).optional(),
27
+ rawInputPeak: z.number().min(0).max(1).optional(),
28
+ inputSilenced: z.boolean(),
29
+ micGainDb: z.number(),
30
+ micGainMinDb: z.number(),
31
+ micGainMaxDb: z.number(),
32
+ monitorGainDb: z.number(),
33
+ monitorGainMinDb: z.number(),
34
+ monitorGainMaxDb: z.number(),
35
+ micDevice: AndroidOperatorAudioDeviceSchema.nullable(),
36
+ speakerDevice: AndroidOperatorAudioDeviceSchema.nullable(),
37
+ lastError: z.string().nullable(),
38
+ });
39
+ export type AndroidOperatorAudioStatus = z.infer<typeof AndroidOperatorAudioStatusSchema>;
40
+
41
+ export const AndroidOperatorAudioGainUpdateSchema = z.object({
42
+ micGainDb: z.number(),
43
+ });
44
+ export type AndroidOperatorAudioGainUpdate = z.infer<typeof AndroidOperatorAudioGainUpdateSchema>;
45
+
46
+ export const AndroidOperatorAudioMonitorGainUpdateSchema = z.object({
47
+ monitorGainDb: z.number(),
48
+ });
49
+ export type AndroidOperatorAudioMonitorGainUpdate = z.infer<typeof AndroidOperatorAudioMonitorGainUpdateSchema>;
@@ -29,6 +29,29 @@ export const AudioSidecarErrorSchema = z.object({
29
29
 
30
30
  export type AudioSidecarError = z.infer<typeof AudioSidecarErrorSchema>;
31
31
 
32
+ export const AudioSidecarPhaseSchema = z.enum([
33
+ 'startStream',
34
+ 'startOutput',
35
+ 'runtime',
36
+ ]);
37
+
38
+ export const AudioSidecarClassificationSchema = z.enum([
39
+ 'startup-failure',
40
+ 'runtime-loss',
41
+ 'sample-rate-fallback',
42
+ 'sample-rate-fallback-failed',
43
+ 'device-unavailable',
44
+ 'disabled',
45
+ ]);
46
+
47
+ export const AudioSidecarFallbackSchema = z.object({
48
+ active: z.boolean(),
49
+ fromSampleRate: z.number().int().positive().nullable(),
50
+ toSampleRate: z.number().int().positive().nullable(),
51
+ persisted: z.boolean().optional(),
52
+ reason: z.string().optional(),
53
+ });
54
+
32
55
  /**
33
56
  * AudioSidecar 状态变化载荷。
34
57
  * 前端以此渲染 RadioControl 中的音频副状态 spinner + Popover。
@@ -48,6 +71,18 @@ export const AudioSidecarStatusPayloadSchema = z.object({
48
71
  lastError: AudioSidecarErrorSchema.nullable(),
49
72
  /** 当前目标设备名(来自 AudioConfig.inputDeviceName) */
50
73
  deviceName: z.string().nullable(),
74
+ /** 当前失败/重试阶段 */
75
+ phase: AudioSidecarPhaseSchema.optional(),
76
+ /** 机器可读的失败分类,用于 UI 展示和诊断 */
77
+ classification: AudioSidecarClassificationSchema.optional(),
78
+ /** 具体受影响的输入/输出设备名 */
79
+ affectedDeviceName: z.string().nullable().optional(),
80
+ /** 失败时使用的采样率 */
81
+ sampleRate: z.number().int().positive().nullable().optional(),
82
+ /** 自动降级状态 */
83
+ fallback: AudioSidecarFallbackSchema.optional(),
84
+ /** 当前重试原因摘要 */
85
+ retryReason: z.string().optional(),
51
86
  });
52
87
 
53
88
  export type AudioSidecarStatusPayload = z.infer<typeof AudioSidecarStatusPayloadSchema>;
@@ -1,5 +1,69 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ export const AudioDeviceBackendSchema = z.enum([
4
+ 'rtaudio',
5
+ 'android',
6
+ 'icom-wlan',
7
+ 'openwebrx',
8
+ 'tci',
9
+ 'network',
10
+ ]);
11
+
12
+ export const AudioDeviceKindSchema = z.enum([
13
+ 'usb',
14
+ 'wired-headset',
15
+ 'wired-headphones',
16
+ 'analog-line',
17
+ 'builtin-mic',
18
+ 'builtin-speaker',
19
+ 'network',
20
+ 'virtual',
21
+ 'unknown',
22
+ ]);
23
+
24
+ export const AudioDeviceTransportSchema = z.enum([
25
+ 'usb',
26
+ 'analog',
27
+ 'builtin',
28
+ 'network',
29
+ 'virtual',
30
+ 'unknown',
31
+ ]);
32
+
33
+ export const AudioDeviceConnectorSchema = z.enum([
34
+ 'usb',
35
+ '3.5mm',
36
+ 'builtin',
37
+ 'network',
38
+ 'virtual',
39
+ 'unknown',
40
+ ]);
41
+
42
+ export const AudioDeviceRouteStateSchema = z.enum([
43
+ 'idle',
44
+ 'opening',
45
+ 'verified',
46
+ 'lost',
47
+ 'unavailable',
48
+ 'failed',
49
+ ]);
50
+
51
+ export const AudioDeviceCapabilitiesSchema = z.object({
52
+ sampleRates: z.array(z.number().int().positive()).optional(),
53
+ bufferSizes: z.array(z.number().int().positive()).optional(),
54
+ channelCounts: z.array(z.number().int().positive()).optional(),
55
+ sampleFormats: z.array(z.enum(['float32', 'int16'])).optional(),
56
+ channelModes: z.array(z.enum(['mono', 'left', 'right', 'both'])).optional(),
57
+ sampleRateConfigurable: z.boolean().optional(),
58
+ bufferSizeConfigurable: z.boolean().optional(),
59
+ sampleFormatConfigurable: z.boolean().optional(),
60
+ channelModeConfigurable: z.boolean().optional(),
61
+ outputRouteAck: z.boolean().optional(),
62
+ fixedSampleRate: z.number().int().positive().optional(),
63
+ fixedBufferSize: z.number().int().positive().optional(),
64
+ fixedChannelCount: z.number().int().positive().optional(),
65
+ });
66
+
3
67
  // 音频设备信息
4
68
  export const AudioDeviceSchema = z.object({
5
69
  id: z.string(),
@@ -12,6 +76,26 @@ export const AudioDeviceSchema = z.object({
12
76
  availability: z.enum(['available', 'cached', 'active']).optional(),
13
77
  isActiveByTx5dr: z.boolean().optional(),
14
78
  lastSeenAt: z.number().int().positive().optional(),
79
+ backend: AudioDeviceBackendSchema.optional(),
80
+ kind: AudioDeviceKindSchema.optional(),
81
+ routeKey: z.string().min(1).optional(),
82
+ transport: AudioDeviceTransportSchema.optional(),
83
+ connector: AudioDeviceConnectorSchema.optional(),
84
+ clientConnected: z.boolean().optional(),
85
+ routeVerified: z.boolean().optional(),
86
+ routeState: AudioDeviceRouteStateSchema.optional(),
87
+ failureReason: z.string().min(1).optional(),
88
+ capabilities: AudioDeviceCapabilitiesSchema.optional(),
89
+ /** Human-readable secondary label (radio identity · VID:PID · USB path). */
90
+ detail: z.string().optional(),
91
+ /** Stable hardware identity when available (e.g. usb:1-7.2.4). */
92
+ hardwareId: z.string().optional(),
93
+ vendorId: z.string().optional(),
94
+ productId: z.string().optional(),
95
+ serialNumber: z.string().optional(),
96
+ usbPath: z.string().optional(),
97
+ alsaCard: z.number().int().nonnegative().optional(),
98
+ alsaCardId: z.string().optional(),
15
99
  });
16
100
 
17
101
  export const AudioDeviceResolutionStatusSchema = z.enum([
@@ -23,6 +107,9 @@ export const AudioDeviceResolutionStatusSchema = z.enum([
23
107
 
24
108
  export const AudioDeviceResolutionSchema = z.object({
25
109
  configuredDeviceName: z.string().nullable(),
110
+ configuredRouteKey: z.string().nullable().optional(),
111
+ configuredDeviceId: z.string().nullable().optional(),
112
+ configuredHardwareId: z.string().nullable().optional(),
26
113
  configuredDevice: AudioDeviceSchema.nullable(),
27
114
  effectiveDevice: AudioDeviceSchema.nullable(),
28
115
  status: AudioDeviceResolutionStatusSchema,
@@ -37,6 +124,13 @@ export const AudioDeviceResolutionSetSchema = z.object({
37
124
  export const AudioOutputSampleFormatSchema = z.enum(['float32', 'int16']);
38
125
  export const AudioOutputChannelModeSchema = z.enum(['mono', 'left', 'right', 'both']);
39
126
 
127
+ /**
128
+ * How USB/ACC capture PCM should be interpreted before the internal 12 kHz AF ring.
129
+ * - af: already-demodulated SSB baseband (default)
130
+ * - icom-12k-if: Icom ACC/USB Output Select=IF (~12 kHz IF); software SSB demod required
131
+ */
132
+ export const AudioInputSignalTypeSchema = z.enum(['af', 'icom-12k-if']);
133
+
40
134
  // 音频设备列表响应
41
135
  export const AudioDevicesResponseSchema = z.object({
42
136
  inputDevices: z.array(AudioDeviceSchema),
@@ -48,13 +142,25 @@ export const AudioDevicesResponseSchema = z.object({
48
142
  // 音频设备设置请求
49
143
  export const AudioDeviceSettingsSchema = z.object({
50
144
  inputDeviceName: z.string().optional(), // 使用设备名称而非ID
51
- outputDeviceName: z.string().optional(), // 使用设备名称而非ID
145
+ outputDeviceName: z.string().optional(), // 使用设备名称而非ID
146
+ inputRouteKey: z.string().min(1).nullable().optional(),
147
+ outputRouteKey: z.string().min(1).nullable().optional(),
148
+ /** Runtime device id (e.g. input-130). Preferred over name when unique. */
149
+ inputDeviceId: z.string().optional(),
150
+ outputDeviceId: z.string().optional(),
151
+ /** Stable hardware id when available (e.g. usb:1-7.2.4). Preferred over runtime id. */
152
+ inputHardwareId: z.string().optional(),
153
+ outputHardwareId: z.string().optional(),
52
154
  inputSampleRate: z.number().optional(),
53
155
  outputSampleRate: z.number().optional(),
54
156
  inputBufferSize: z.number().optional(),
55
157
  outputBufferSize: z.number().optional(),
56
158
  outputSampleFormat: AudioOutputSampleFormatSchema.optional(),
57
159
  outputChannelMode: AudioOutputChannelModeSchema.optional(),
160
+ /** Capture signal interpretation. Defaults to demodulated AF baseband. */
161
+ inputSignalType: AudioInputSignalTypeSchema.optional(),
162
+ /** IF center frequency in Hz when inputSignalType is icom-12k-if (default 12000). */
163
+ ifCenterHz: z.number().positive().max(24000).optional(),
58
164
  sampleRate: z.number().optional(),
59
165
  bufferSize: z.number().optional(),
60
166
  });
@@ -69,7 +175,7 @@ export const AudioDeviceSettingsResponseSchema = z.object({
69
175
 
70
176
  export const AudioSettingsResolveRequestSchema = z.object({
71
177
  audio: AudioDeviceSettingsSchema,
72
- radioType: z.enum(['none', 'network', 'serial', 'icom-wlan']).optional(),
178
+ radioType: z.enum(['none', 'network', 'serial', 'icom-wlan', 'tci']).optional(),
73
179
  });
74
180
 
75
181
  export const AudioSettingsResolveResponseSchema = z.object({
@@ -110,11 +216,18 @@ export const VolumeGainSchema = z.object({
110
216
 
111
217
  // 导出类型
112
218
  export type AudioDevice = z.infer<typeof AudioDeviceSchema>;
219
+ export type AudioDeviceBackend = z.infer<typeof AudioDeviceBackendSchema>;
220
+ export type AudioDeviceKind = z.infer<typeof AudioDeviceKindSchema>;
221
+ export type AudioDeviceTransport = z.infer<typeof AudioDeviceTransportSchema>;
222
+ export type AudioDeviceConnector = z.infer<typeof AudioDeviceConnectorSchema>;
223
+ export type AudioDeviceRouteState = z.infer<typeof AudioDeviceRouteStateSchema>;
224
+ export type AudioDeviceCapabilities = z.infer<typeof AudioDeviceCapabilitiesSchema>;
113
225
  export type AudioDeviceResolutionStatus = z.infer<typeof AudioDeviceResolutionStatusSchema>;
114
226
  export type AudioDeviceResolution = z.infer<typeof AudioDeviceResolutionSchema>;
115
227
  export type AudioDeviceResolutionSet = z.infer<typeof AudioDeviceResolutionSetSchema>;
116
228
  export type AudioOutputSampleFormat = z.infer<typeof AudioOutputSampleFormatSchema>;
117
229
  export type AudioOutputChannelMode = z.infer<typeof AudioOutputChannelModeSchema>;
230
+ export type AudioInputSignalType = z.infer<typeof AudioInputSignalTypeSchema>;
118
231
  export type AudioDevicesResponse = z.infer<typeof AudioDevicesResponseSchema>;
119
232
  export type AudioDeviceSettings = z.infer<typeof AudioDeviceSettingsSchema>;
120
233
  export type AudioDeviceSettingsResponse = z.infer<typeof AudioDeviceSettingsResponseSchema>;