@tx5dr/plugin-api 1.7.12 → 2.5.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 (181) hide show
  1. package/README.md +475 -17
  2. package/dist/__tests__/adif.test.js +107 -0
  3. package/dist/__tests__/adif.test.js.map +1 -1
  4. package/dist/__tests__/capability-context.test.d.ts +2 -0
  5. package/dist/__tests__/capability-context.test.d.ts.map +1 -0
  6. package/dist/__tests__/capability-context.test.js +99 -0
  7. package/dist/__tests__/capability-context.test.js.map +1 -0
  8. package/dist/__tests__/testing-utils.test.js +234 -15
  9. package/dist/__tests__/testing-utils.test.js.map +1 -1
  10. package/dist/capabilities.d.ts +38 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +50 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/compatibility.d.ts +15 -0
  15. package/dist/compatibility.d.ts.map +1 -0
  16. package/dist/compatibility.js +71 -0
  17. package/dist/compatibility.js.map +1 -0
  18. package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
  19. package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
  20. package/dist/contest-logbook-ui/contest-log.html +13 -0
  21. package/dist/context.d.ts +125 -54
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/definition.d.ts +98 -21
  24. package/dist/definition.d.ts.map +1 -1
  25. package/dist/definition.js +8 -1
  26. package/dist/definition.js.map +1 -1
  27. package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
  28. package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
  29. package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
  30. package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
  31. package/dist/ft8/index.d.ts +22 -0
  32. package/dist/ft8/index.d.ts.map +1 -0
  33. package/dist/ft8/index.js +176 -0
  34. package/dist/ft8/index.js.map +1 -0
  35. package/dist/helpers.d.ts +417 -132
  36. package/dist/helpers.d.ts.map +1 -1
  37. package/dist/hooks.d.ts +56 -34
  38. package/dist/hooks.d.ts.map +1 -1
  39. package/dist/host-dependencies.d.ts +101 -0
  40. package/dist/host-dependencies.d.ts.map +1 -1
  41. package/dist/index.d.ts +20 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +12 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/runtime.d.ts +539 -13
  46. package/dist/runtime.d.ts.map +1 -1
  47. package/dist/runtime.js +9 -1
  48. package/dist/runtime.js.map +1 -1
  49. package/dist/settings.d.ts +31 -0
  50. package/dist/settings.d.ts.map +1 -1
  51. package/dist/simulation.d.ts +60 -0
  52. package/dist/simulation.d.ts.map +1 -0
  53. package/dist/simulation.js +2 -0
  54. package/dist/simulation.js.map +1 -0
  55. package/dist/sync.d.ts +98 -6
  56. package/dist/sync.d.ts.map +1 -1
  57. package/dist/sync.js +6 -0
  58. package/dist/sync.js.map +1 -1
  59. package/dist/testing/index.d.ts +74 -12
  60. package/dist/testing/index.d.ts.map +1 -1
  61. package/dist/testing/index.js +372 -105
  62. package/dist/testing/index.js.map +1 -1
  63. package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
  64. package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
  65. package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
  66. package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
  67. package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
  68. package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
  69. package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
  70. package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
  71. package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
  72. package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
  73. package/dist/toolkit/contest/ContestLogbook.js +845 -0
  74. package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
  75. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
  76. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
  77. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
  78. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
  79. package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
  80. package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
  81. package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
  82. package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
  83. package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
  84. package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
  85. package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
  86. package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
  87. package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
  88. package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
  89. package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
  90. package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
  91. package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
  92. package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
  93. package/dist/toolkit/contest/DefaultContestSession.js +387 -0
  94. package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
  95. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
  96. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
  97. package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
  98. package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
  99. package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
  100. package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
  101. package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
  102. package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
  103. package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
  104. package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
  105. package/dist/toolkit/contest/FT8ContestModules.js +247 -0
  106. package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
  107. package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
  108. package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
  109. package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
  110. package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
  111. package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
  112. package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
  113. package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
  114. package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
  115. package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
  116. package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
  117. package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
  118. package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
  119. package/dist/toolkit/contest/index.d.ts +14 -0
  120. package/dist/toolkit/contest/index.d.ts.map +1 -0
  121. package/dist/toolkit/contest/index.js +14 -0
  122. package/dist/toolkit/contest/index.js.map +1 -0
  123. package/dist/toolkit/index.d.ts +3 -0
  124. package/dist/toolkit/index.d.ts.map +1 -0
  125. package/dist/toolkit/index.js +3 -0
  126. package/dist/toolkit/index.js.map +1 -0
  127. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
  128. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
  129. package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
  130. package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
  131. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
  132. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
  133. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
  134. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
  135. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
  136. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
  137. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
  138. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
  139. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
  140. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
  141. package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
  142. package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
  143. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
  144. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
  145. package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
  146. package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
  147. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
  148. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
  149. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
  150. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
  151. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
  152. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
  153. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
  154. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
  155. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
  156. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
  157. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
  158. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
  159. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
  160. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
  161. package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
  162. package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
  163. package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
  164. package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
  165. package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
  166. package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
  167. package/dist/toolkit/parallel-qso/index.d.ts +8 -0
  168. package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
  169. package/dist/toolkit/parallel-qso/index.js +8 -0
  170. package/dist/toolkit/parallel-qso/index.js.map +1 -0
  171. package/dist/utils/adif.d.ts +1 -1
  172. package/dist/utils/adif.d.ts.map +1 -1
  173. package/dist/utils/adif.js +96 -51
  174. package/dist/utils/adif.js.map +1 -1
  175. package/dist/utils/qso-text-fields.d.ts +7 -1
  176. package/dist/utils/qso-text-fields.d.ts.map +1 -1
  177. package/dist/utils/qso-text-fields.js +74 -6
  178. package/dist/utils/qso-text-fields.js.map +1 -1
  179. package/package.json +28 -6
  180. package/src/bridge.d.ts +3 -0
  181. package/tokens.css +51 -0
package/README.md CHANGED
@@ -15,12 +15,14 @@ npm install --save-dev @tx5dr/plugin-api
15
15
  ### TypeScript
16
16
 
17
17
  ```typescript
18
- import type { PluginDefinition, PluginContext } from '@tx5dr/plugin-api';
18
+ import { definePlugin } from '@tx5dr/plugin-api';
19
19
 
20
- const plugin: PluginDefinition = {
20
+ const plugin = definePlugin({
21
+ apiVersion: 2,
21
22
  name: 'my-plugin',
22
23
  version: '1.0.0',
23
24
  type: 'utility',
25
+ permissions: [],
24
26
  hooks: {
25
27
  onDecode(messages, ctx) {
26
28
  for (const msg of messages) {
@@ -28,7 +30,7 @@ const plugin: PluginDefinition = {
28
30
  }
29
31
  },
30
32
  },
31
- };
33
+ });
32
34
 
33
35
  export default plugin;
34
36
  ```
@@ -36,11 +38,14 @@ export default plugin;
36
38
  ### JavaScript (with JSDoc types)
37
39
 
38
40
  ```javascript
39
- /** @type {import('@tx5dr/plugin-api').PluginDefinition} */
40
- export default {
41
+ import { definePlugin } from '@tx5dr/plugin-api';
42
+
43
+ export default definePlugin({
44
+ apiVersion: 2,
41
45
  name: 'my-plugin',
42
46
  version: '1.0.0',
43
47
  type: 'utility',
48
+ permissions: [],
44
49
  hooks: {
45
50
  onDecode(messages, ctx) {
46
51
  for (const msg of messages) {
@@ -48,32 +53,269 @@ export default {
48
53
  }
49
54
  },
50
55
  },
51
- };
56
+ });
52
57
  ```
53
58
 
54
59
  ## Exports
55
60
 
56
61
  | Subpath | Description |
57
62
  |---------|-------------|
58
- | `@tx5dr/plugin-api` | Core types: `PluginDefinition`, `PluginContext`, `PluginHooks`, helper interfaces, radio/message types |
59
- | `@tx5dr/plugin-api/testing` | Mock factories for unit testing: `createMockContext()`, `createMockSlotInfo()`, `createMockParsedMessage()` |
63
+ | `@tx5dr/plugin-api` | `definePlugin()`, capability-derived contexts, hooks, structured command ports, and radio/message types |
64
+ | `@tx5dr/plugin-api/testing` | Mock factories for unit testing: `createMockContext()`, `createMockSlotInfo()`, `createMockParsedMessage()`, `createMockEventBus()` |
65
+ | `@tx5dr/plugin-api/contest` | Composable FT8/FT4 exchange, completion, dupe, scoring, submission, session and plugin assembly modules |
66
+ | `@tx5dr/plugin-api/toolkit` | Advanced contest and parallel-QSO building blocks |
60
67
  | `@tx5dr/plugin-api/bridge` | Ambient type declarations for the iframe Bridge SDK (`window.tx5dr`) |
61
68
 
69
+ Release boundary: `2.0.1` is the containment release for the existing
70
+ experimental/advanced `./toolkit` surface. The stable `./contest` entry point,
71
+ root re-exports and `ft8-contest` scaffold start at `2.5.0`; the publish workflow
72
+ rejects an older version when that stable export is present.
73
+
74
+ Plugin compatibility uses the bundled `@tx5dr/plugin-api` SemVer, not the
75
+ TX-5DR product/nightly version. Marketplace plugins declare
76
+ `minPluginApiVersion`; `apiVersion: 2` remains the separate runtime ABI marker.
77
+
78
+ ## FT8 Contest Composition
79
+
80
+ Contest rules are public, replaceable modules. `composeFT8ContestPlugin()` is a
81
+ convenience entry point, not a required base class: a plugin may also import the
82
+ same modules and connect them with `definePlugin()` itself.
83
+
84
+ ```ts
85
+ import {
86
+ cabrilloSubmission,
87
+ CONTEST_LOGBOOK_PERMISSIONS,
88
+ composeFT8ContestPlugin,
89
+ defineFT8Contest,
90
+ distancePoints,
91
+ fixedWeekendEdition,
92
+ gridExchange,
93
+ requireExchangeAndFinalAck,
94
+ standardFT8ContestLogbook,
95
+ } from '@tx5dr/plugin-api/contest';
96
+ import { createContestRuntime } from './runtime.js';
97
+
98
+ const contest = defineFT8Contest({
99
+ id: 'example-ft8',
100
+ rulesetVersion: '2026.1',
101
+ edition: fixedWeekendEdition({
102
+ id: '2026',
103
+ startAt: '2026-08-29T00:00:00Z',
104
+ endAt: '2026-08-30T00:00:00Z',
105
+ }),
106
+ bands: ['80M', '40M', '20M', '15M', '10M'],
107
+ exchange: gridExchange(),
108
+ completion: requireExchangeAndFinalAck(),
109
+ scoring: distancePoints({ stepKm: 3000 }),
110
+ presentation: {
111
+ summary: 'FT8/FT4 grid exchange contest.',
112
+ scoring: 'One point plus one point per 3000 km step; grid fields provide multipliers.',
113
+ exchange: 'Four-character Maidenhead grid',
114
+ },
115
+ submission: cabrilloSubmission({
116
+ headers: () => [['CONTEST', 'EXAMPLE-FT8']],
117
+ qsoLine: (qso) => `QSO: ${qso.callsign}`,
118
+ }),
119
+ });
120
+
121
+ export default composeFT8ContestPlugin({
122
+ name: 'example-ft8',
123
+ version: '1.0.0',
124
+ minPluginApiVersion: '2.5.0',
125
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
126
+ contest,
127
+ runtime: createContestRuntime,
128
+ logbook: standardFT8ContestLogbook({ contest }),
129
+ });
130
+ ```
131
+
132
+ For fixed-point sprints, prefix/DXCC/grid multipliers, or contests that need
133
+ zero-point QSOs to remain multiplier-eligible, combine `fixedPoints()`,
134
+ `scoreBy()` and `multiplierKeysFrom()` with your own contest metadata accessor.
135
+ `distancePoints()` is the common shortcut for distance-based FT8/FT4 scoring,
136
+ not the only way to compose a contest.
137
+
138
+ The definition defaults to FT8, one QSO, one signal, human initiation and
139
+ callsign-per-band duplicate checking. Completion remains mandatory because a
140
+ plugin must choose its RF fail-closed evidence policy explicitly. The assembler
141
+ maps human initiation and QSO/simultaneous-signal limits into Host-enforced
142
+ strategy capabilities. `cycleRelation` remains a runtime-adapter responsibility
143
+ because it depends on the contest protocol's lane semantics. Optional
144
+ `defaultContestSession()` and `defaultContestWorkbench()` hide storage keys and
145
+ page routing while keeping their module interfaces replaceable. For a complete
146
+ operator-facing contest logbook, use `standardFT8ContestLogbook({ contest })`;
147
+ it supplies the standard page, panel, bridge protocol, ADIF import/export and
148
+ official submission format. The `ft8-contest` scaffold copies the canonical
149
+ page assets automatically. The definition's `presentation` metadata is shown
150
+ as collapsible rule and scoring details with a link to the official rules; when
151
+ metadata is absent, the corresponding detail is omitted.
152
+ The standard logbook also derives the FrameTable's new-callsign and contest
153
+ multiplier presentation from the independent contest session, and the composer
154
+ adds it to the strategy snapshot automatically.
155
+
156
+ Single-stream contests may add `strategyFeatures.targetQueue` explicitly.
157
+ Definitions with more than one concurrent QSO automatically require the Host's
158
+ parallel queue contract; the assembler validates the returned runtime as soon
159
+ as it is created. Contest identities, editions and rule-module entry points are
160
+ snapshotted when `defineFT8Contest()` returns.
161
+
162
+ Use `projectFT8ContestQsos()`, `scoreFT8ContestQsos()` and
163
+ `formatFT8ContestSubmission()` as the common eligibility path. They apply the
164
+ edition window, supported modes/bands, review/excluded state and dupe policy
165
+ before score or export. Allocate serials with `nextContestSerial()` inside a
166
+ session `transact()` planner so a revision retry recalculates the serial from the
167
+ latest durable QSO snapshot.
168
+
169
+ For a contest application, declare the shared session capabilities and let the
170
+ facade own Host session keys, event topics and revision-conflict retries:
171
+
172
+ ```ts
173
+ import {
174
+ CONTEST_SESSION_PERMISSIONS,
175
+ createContestQsoEnvelopeAdapter,
176
+ defaultContestSession,
177
+ } from '@tx5dr/plugin-api/contest';
178
+
179
+ const session = defaultContestSession({
180
+ create: () => ({ schemaVersion: 1, revision: 0, settings: {} }),
181
+ });
182
+ const contestEnvelope = createContestQsoEnvelopeAdapter(contest);
183
+
184
+ const contestEntry = contestEnvelope.create({
185
+ sent: { grid: 'PL04' },
186
+ received: { grid: 'FN31' },
187
+ annotations: { status: 'review' },
188
+ });
189
+
190
+ // Inside a current Host callback:
191
+ await session.access(ctx).transact(
192
+ (snapshot) => planImportMutations(parsedImport, snapshot),
193
+ { reason: 'import' },
194
+ );
195
+ ```
196
+
197
+ The facade opens the Host plugin-session on each current invocation, exposes
198
+ health/query/snapshot, retries a snapshot-planned QSO batch up to three times,
199
+ and publishes UI/event-bus notifications after commit. The plugin still parses
200
+ the file and owns its exchange, review fields and mutations. It never receives
201
+ the raw Host session handle. Use `CONTEST_SESSION_PERMISSIONS` as the plugin's
202
+ permissions when composing this default module.
203
+
204
+ Session identity includes plugin owner, station callsign, `contestId`,
205
+ `editionId` and `rulesetVersion`. The default facade is durable-only because its
206
+ cleanup context cannot destroy a runtime Host session; runtime practice/session
207
+ lifecycles must use strategy `logbookSessionEffects`. The envelope adapter
208
+ freezes the contest identity and uses the definition's typed exchange codec for
209
+ create/validation.
210
+
211
+ `defaultContestWorkbench()` exports a recommended `ContestWorkbenchCommand`
212
+ union for `get-state`, settings, QSO review, import and export action names plus
213
+ a narrow `ContestWorkbenchViewModel`. It is not a closed action DSL: plugins may
214
+ extend `ContestWorkbenchRequest` with custom actions. The plugin supplies typed
215
+ decoding, handlers and contest-specific generic fields; the SDK does not impose
216
+ a UI framework.
217
+
218
+ ## Capability Model
219
+
220
+ Privileged Host APIs use an allowlist model. A plugin must declare every
221
+ capability in `permissions`; the Host then projects only those properties into
222
+ that plugin's context. Undeclared properties are absent from both the inferred
223
+ TypeScript type and the runtime object. Use `definePlugin()` without manually
224
+ widening callbacks to `PluginContext`, otherwise TypeScript cannot preserve the
225
+ literal permission tuple.
226
+
227
+ Capabilities grant structured Host ports, not physical device ownership. The
228
+ capability-derived context does not directly expose raw PTT, audio playback,
229
+ the mixer, encoder, physical frame lease, arbitrary radio capability writes,
230
+ or global emergency stop. Strategy runtimes receive a narrower speculative
231
+ context and return declarative decisions; they never receive command ports.
232
+
233
+ For a strategy plugin, `type: 'strategy'` plus `apiVersion: 2` is the explicit
234
+ declaration that it may produce RF decisions when selected by an operator. It
235
+ does not need `operator:transmit-control`. That permission is reserved for
236
+ utility plugins that need the imperative, Host-coordinated
237
+ `ctx.operatorCommands` port.
238
+
239
+ API v2 is required for strategy plugins and any plugin requesting a mutation
240
+ capability. The Host validates and freezes the loaded definition so permissions
241
+ cannot be expanded after load.
242
+
243
+ This is a Host API contract, not a sandbox for hostile Node.js code. Third-party
244
+ plugins currently execute in the server process; process isolation is a
245
+ separate security boundary.
246
+
247
+ ## Data Ownership and Callback Lifetime
248
+
249
+ Configuration, hook arguments, query results and messages cross the Host boundary by value. Plugins may modify their local copies, but changes are not persisted until they call an explicit API such as `ctx.updateConfig()`, `store.set()` or a command port. UI/config/KV channels accept JSON-compatible values; hooks, strategy results and EventBus payloads accept structured-clone-compatible values. Functions, cycles, Host handles and other unsupported values are rejected with `PLUGIN_DATA_NOT_SERIALIZABLE`.
250
+
251
+ Host capabilities such as `ctx.ui`, `ctx.logbook`, radio command ports, network sockets and native `Response` objects are live handles. Use them only before the current Host callback settles. A handle retained past timeout, reload or unload rejects with `PLUGIN_INVOCATION_EXPIRED`; use Host timers and callbacks for later work instead of detached continuations.
252
+
253
+ ## Operator Transmit Control
254
+
255
+ Utility plugins that need to submit operator commands must declare
256
+ `operator:transmit-control` and submit one of the high-level commands accepted
257
+ by `ctx.operatorCommands`. The permission grants API access; it does not by
258
+ itself classify the plugin as an automatic caller.
259
+
260
+ Automatic calling plugins implement `isAutoCallEnabled()`. This both gates the
261
+ command port and opts the plugin into the operator auto-call indicator and pause
262
+ controls:
263
+
264
+ ```ts
265
+ const plugin = definePlugin({
266
+ apiVersion: 2,
267
+ name: 'scheduled-caller',
268
+ version: '1.0.0',
269
+ type: 'utility',
270
+ permissions: ['operator:transmit-control'],
271
+ isAutoCallEnabled: (ctx) => ctx.config.enabled === true,
272
+ hooks: {
273
+ async onTimer(_timerId, ctx) {
274
+ await ctx.operatorCommands.submit({
275
+ type: 'request-call',
276
+ callsign: 'W1AW',
277
+ });
278
+ },
279
+ },
280
+ });
281
+ ```
282
+
283
+ Integrations such as remote-control protocol bridges that may submit occasional
284
+ commands but do not autonomously originate calls implement
285
+ `isTransmitControlEnabled()` instead. They receive the same guarded command
286
+ port but are not shown or paused as auto-call plugins.
287
+
288
+ The Host allocates an operator command epoch and routes the request through the
289
+ operator/frame coordinators. Plugins cannot directly key or unkey the radio.
290
+
62
291
  ## Radio Permissions
63
292
 
64
- Server-side plugins can use `ctx.radio` to inspect negotiated radio capabilities and, when explicitly permitted, control radio capabilities or physical power:
293
+ `ctx.radio` always exposes a small read-only operating snapshot. Additional
294
+ radio capabilities require explicit declarations:
65
295
 
66
296
  ```ts
67
- permissions: ['radio:read', 'radio:control', 'radio:power']
297
+ permissions: ['radio:read', 'radio:control', 'radio:tuner-control', 'radio:power']
68
298
  ```
69
299
 
70
- - `radio:read` enables `ctx.radio.capabilities.getSnapshot()` and `ctx.radio.power.getSupport()`.
71
- - `radio:control` enables `ctx.radio.setFrequency()` and `ctx.radio.capabilities.write()`.
72
- - `radio:power` enables `ctx.radio.power.set('on' | 'off' | 'standby' | 'operate')`.
73
- - `ctx.radio.mode` is always readable and exposes the current best-known operating mode using ADIF `MODE`/`SUBMODE` semantics, for example `SSB` + `USB` in voice USB.
300
+ - `radio:read` exposes `ctx.radioCapabilities` and the read-only `ctx.radioPower` view.
301
+ - `radio:control` exposes Host-arbitrated `set-frequency` and `switch-band` commands.
302
+ - `switch-band` can include `autoTune: true` when `radio:tuner-control` is also declared; the Host keeps the complete operation inside one physical-idle fence.
303
+ - `radio:tuner-control` exposes only `set-enabled` and `start-manual-tune` through `ctx.radioTunerCommands`.
304
+ - Radio writes reject while Digital, Voice, CW, Tune or manual PTT owns the physical transmitter; they never interrupt that transmission.
305
+ - `radio:power` exposes `ctx.radioPowerCommands.submit({ type: 'set-power', state })`.
306
+ - `ctx.radio.mode` remains read-only and uses ADIF `MODE`/`SUBMODE` semantics.
74
307
 
75
308
  These APIs are not exposed directly to iframe pages; custom UI should call a server-side page handler.
76
309
 
310
+ ## Logbook Permissions
311
+
312
+ - `logbook:read` exposes only query and worked-status methods on `ctx.logbook`.
313
+ - `logbook:write` exposes durable `addQSO()`/`updateQSO()` mutations in addition to reads.
314
+ - `logbook:sync` exposes `ctx.logbookSync` for registering a Host-managed sync provider.
315
+
316
+ Write completion means the Host logbook durability contract has completed.
317
+ Logbook APIs never grant PTT or frame lifecycle control.
318
+
77
319
  ## Host Settings Permissions
78
320
 
79
321
  Server-side plugins can use `ctx.settings` to read or update a safe whitelist of host settings when the manifest declares the matching permission. Each settings namespace uses one read/write permission:
@@ -89,9 +331,10 @@ Server-side plugins can use `ctx.settings` to read or update a safe whitelist of
89
331
  | `ctx.settings.ntp` | `settings:ntp` | `get()`, `update({ servers })` |
90
332
 
91
333
  ```ts
92
- import type { PluginDefinition } from '@tx5dr/plugin-api';
334
+ import { definePlugin } from '@tx5dr/plugin-api';
93
335
 
94
- const plugin: PluginDefinition = {
336
+ const plugin = definePlugin({
337
+ apiVersion: 2,
95
338
  name: 'station-policy',
96
339
  version: '1.0.0',
97
340
  type: 'utility',
@@ -102,13 +345,178 @@ const plugin: PluginDefinition = {
102
345
  await ctx.settings.station.update({ callsign: 'W1AW' });
103
346
  },
104
347
  },
105
- };
348
+ });
106
349
 
107
350
  export default plugin;
108
351
  ```
109
352
 
110
353
  The whitelist intentionally excludes authentication tokens, operator CRUD, hardware radio connection settings, audio devices, rigctld, OpenWebRX, profiles, and server host/port settings. These APIs are not exposed directly to iframe pages; custom UI should call a server-side page handler with `window.tx5dr.invoke()`.
111
354
 
355
+ ## Plugin Event Bus
356
+
357
+ Server-side plugins can exchange in-process messages through `ctx.eventBus`, a topic-based pub/sub bus scoped to the host process. Payloads use structured-clone semantics and each subscriber receives an independent value, enabling loose coupling without shared mutable state. Functions, promises, weak collections, and host capability objects cannot be published.
358
+
359
+ ### Permission
360
+
361
+ Declare `plugin:event-bus` in the manifest to enable the bus:
362
+
363
+ ```ts
364
+ permissions: ['plugin:event-bus']
365
+ ```
366
+
367
+ `ctx.eventBus` is optional and should be feature-detected before use.
368
+
369
+ ### API Summary
370
+
371
+ | Method | Description |
372
+ |--------|-------------|
373
+ | `publish(topic, payload?)` | Fire-and-forget message to all current subscribers of the exact topic. |
374
+ | `subscribe(topic, handler)` | Registers a handler; returns an unsubscribe function. |
375
+
376
+ Every message received by a subscriber is a `PluginEventBusMessage`:
377
+
378
+ ```ts
379
+ interface PluginEventBusMessage {
380
+ topic: string; // The topic this message was published to
381
+ payload: unknown; // Independent structured-clone value
382
+ timestamp: number; // Epoch ms when the host dispatched the message
383
+ publisher: {
384
+ pluginName: string; // Publishing plugin's name
385
+ instanceScope: 'operator' | 'global';
386
+ operatorId?: string; // Present when the publisher is operator-scoped
387
+ };
388
+ }
389
+ ```
390
+
391
+ ### Topic Naming Convention
392
+
393
+ Use dot-separated, plugin-prefixed names to avoid collisions between plugins:
394
+
395
+ ```
396
+ <plugin-name>.<domain>.<event>
397
+ ```
398
+
399
+ Examples:
400
+ - `psk-reporter.spot.sent` — a spot was uploaded to PSK Reporter
401
+ - `callsign-filter.match.found` — a callsign matched a filter rule
402
+ - `logbook-sync.upload.complete` — a logbook sync finished
403
+
404
+ Avoid generic names like `update` or `message` — they will collide.
405
+
406
+ ### Basic Usage
407
+
408
+ ```ts
409
+ import { definePlugin } from '@tx5dr/plugin-api';
410
+
411
+ // Publisher plugin
412
+ const publisher = definePlugin({
413
+ apiVersion: 2,
414
+ name: 'spot-monitor',
415
+ version: '1.0.0',
416
+ type: 'utility',
417
+ permissions: ['plugin:event-bus'],
418
+ hooks: {
419
+ onDecode(messages, ctx) {
420
+ for (const msg of messages) {
421
+ ctx.eventBus.publish('spot-monitor.new-spot', {
422
+ callsign: msg.callsign,
423
+ frequency: msg.frequencyHz,
424
+ });
425
+ }
426
+ },
427
+ },
428
+ });
429
+
430
+ // Subscriber plugin
431
+ const subscriber = definePlugin({
432
+ apiVersion: 2,
433
+ name: 'spot-logger',
434
+ version: '1.0.0',
435
+ type: 'utility',
436
+ permissions: ['plugin:event-bus'],
437
+ hooks: {
438
+ onLoad(ctx) {
439
+ ctx.eventBus.subscribe('spot-monitor.new-spot', (message) => {
440
+ ctx.log.info('received spot', {
441
+ from: message.publisher.pluginName,
442
+ callsign: (message.payload as any).callsign,
443
+ });
444
+ });
445
+ },
446
+ },
447
+ });
448
+ ```
449
+
450
+ ### Cross-Operator Communication
451
+
452
+ Operator-scoped plugins can communicate across operators on the same host. The `publisher` metadata lets subscribers identify which operator sent the message:
453
+
454
+ ```ts
455
+ ctx.eventBus.subscribe('qso-monitor.qso-complete', (message) => {
456
+ const { callsign, band } = message.payload as any;
457
+ ctx.log.info('QSO completed by another operator', {
458
+ operator: message.publisher.operatorId,
459
+ callsign,
460
+ band,
461
+ });
462
+ });
463
+ ```
464
+
465
+ ### Lifecycle and Error Handling
466
+
467
+ - **Auto-cleanup**: the host removes all subscriptions when a plugin instance unloads. No manual cleanup required.
468
+ - **Manual unsubscribe**: call the function returned by `subscribe()` to cancel a single subscription early.
469
+ - **Error isolation**: subscriber exceptions (sync or async) are captured and logged by the host. They never propagate back to the publisher.
470
+ - **Delivery order**: subscribers receive messages in registration order. Async handlers are awaited, but the publisher does not wait for completion.
471
+
472
+ ### Testing
473
+
474
+ Use `createMockEventBus()` from `@tx5dr/plugin-api/testing` to test plugin event bus logic in isolation:
475
+
476
+ ```ts
477
+ import { describe, it, expect, vi } from 'vitest';
478
+ import { createMockEventBus } from '@tx5dr/plugin-api/testing';
479
+
480
+ it('publishes spot data', () => {
481
+ const bus = createMockEventBus({ owner: { pluginName: 'spot-monitor' } });
482
+ const handler = vi.fn();
483
+
484
+ bus.subscribe('spot-monitor.new-spot', handler);
485
+ bus.publish('spot-monitor.new-spot', { callsign: 'W1AW', frequency: 14074000 });
486
+
487
+ expect(handler).toHaveBeenCalledTimes(1);
488
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({
489
+ topic: 'spot-monitor.new-spot',
490
+ payload: { callsign: 'W1AW', frequency: 14074000 },
491
+ publisher: expect.objectContaining({ pluginName: 'spot-monitor' }),
492
+ }));
493
+ });
494
+
495
+ it('tracks published messages', () => {
496
+ const bus = createMockEventBus();
497
+
498
+ bus.publish('topic-a', { value: 1 });
499
+ bus.publish('topic-b', { value: 2 });
500
+
501
+ expect(bus._published).toHaveLength(2);
502
+ expect(bus._published[0].topic).toBe('topic-a');
503
+ });
504
+
505
+ it('unsubscribe prevents further delivery', () => {
506
+ const bus = createMockEventBus();
507
+ const handler = vi.fn();
508
+
509
+ const unsub = bus.subscribe('topic', handler);
510
+ bus.publish('topic', 'first');
511
+ unsub();
512
+ bus.publish('topic', 'second');
513
+
514
+ expect(handler).toHaveBeenCalledTimes(1);
515
+ });
516
+ ```
517
+
518
+ The mock records all published messages in `_published` and exposes the internal `_subscriptions` map for advanced inspection.
519
+
112
520
  ## Bridge SDK Types
113
521
 
114
522
  Plugin iframe pages communicate with the host via the Bridge SDK (`window.tx5dr`), which is automatically injected by the host. To get IDE autocomplete for the Bridge SDK, add the type reference to your project:
@@ -131,6 +539,9 @@ Plugin iframe pages communicate with the host via the Bridge SDK (`window.tx5dr`
131
539
  tx5dr.invoke('getState').then(function(state) {
132
540
  // Full autocomplete for tx5dr methods
133
541
  });
542
+
543
+ // Opens the system default browser in Electron, or a new tab in Web.
544
+ tx5dr.openExternal('https://example.com/rules');
134
545
  ```
135
546
 
136
547
  ## CSS Design Tokens
@@ -153,8 +564,55 @@ Then use the tokens in your plugin CSS:
153
564
  }
154
565
  ```
155
566
 
567
+ The host deliberately does not inject component classes. Plugins own their
568
+ markup and CSS, while the token layer exposes enough primitives to reproduce
569
+ the host's control variants consistently. For example, a plugin can assemble
570
+ its own flat primary button:
571
+
572
+ ```css
573
+ .button {
574
+ min-height: var(--tx5dr-control-height-sm);
575
+ padding-inline: var(--tx5dr-control-padding-x-sm);
576
+ border: 1px solid transparent;
577
+ border-radius: var(--tx5dr-radius-sm);
578
+ font-size: var(--tx5dr-control-font-size-sm);
579
+ font-weight: var(--tx5dr-control-font-weight);
580
+ transition: background-color var(--tx5dr-control-transition);
581
+ }
582
+
583
+ .button--flat-primary {
584
+ color: var(--tx5dr-primary);
585
+ background: var(--tx5dr-primary-soft);
586
+ }
587
+
588
+ .button--flat-primary:hover:not(:disabled) {
589
+ background: var(--tx5dr-primary-soft-hover);
590
+ }
591
+
592
+ .button:disabled {
593
+ opacity: var(--tx5dr-control-disabled-opacity);
594
+ }
595
+ ```
596
+
597
+ The same primitives support plugin-owned `solid`, `flat`, `bordered`, and
598
+ `light` variants. Semantic soft/foreground tokens are available for primary,
599
+ success, warning, and danger controls; compact status, table, alert, shadow,
600
+ focus, spacing, and typography primitives use the corresponding
601
+ `--tx5dr-control-*`, `--tx5dr-chip-*`, `--tx5dr-table-*`, and base tokens.
602
+
156
603
  ## Testing
157
604
 
605
+ 慢速历史扫描、缓存预热或迁移应通过 Host 的启动后任务执行:
606
+
607
+ ```ts
608
+ context.lifecycle.scheduleAfterStartup('history-prewarm', async (signal) => {
609
+ if (signal.aborted) return;
610
+ await rebuildHistory(signal);
611
+ });
612
+ ```
613
+
614
+ Host 会在 Server ready 后调度任务,并在插件实例卸载或重载时自动取消。
615
+
158
616
  ```typescript
159
617
  import { describe, it, expect } from 'vitest';
160
618
  import {
@@ -28,5 +28,112 @@ describe('ADIF QSO mode projection', () => {
28
28
  expect(parsed?.mode).toBe('SSB');
29
29
  expect(parsed?.submode).toBe('USB');
30
30
  });
31
+ it('round-trips the contest identifier used for WW Digi recovery', () => {
32
+ const adif = convertQSOToADIF(createQso({
33
+ mode: 'FT8',
34
+ submode: undefined,
35
+ contestId: 'WW-DIGI',
36
+ }));
37
+ expect(adif).toContain('<contest_id:7>WW-DIGI');
38
+ expect(parseADIFRecord(adif, 'test')?.contestId).toBe('WW-DIGI');
39
+ });
40
+ it('round-trips the atomic contest envelope with Unicode exchange data', () => {
41
+ const contestEntry = {
42
+ schemaVersion: 1,
43
+ contestId: 'FT-CHALLENGE',
44
+ editionId: '2026-weekend-1',
45
+ rulesetVersion: '2026.1',
46
+ sent: { grid: 'PL05', operatorNote: '台北<portable>' },
47
+ received: { grid: 'PM96', snr: '-12' },
48
+ annotations: { status: 'included', transmitter: 1, reviewed: false },
49
+ };
50
+ const adif = convertQSOToADIF(createQso({
51
+ contestId: contestEntry.contestId,
52
+ contestEntry,
53
+ }));
54
+ expect(adif).toContain('<contest_id:12>FT-CHALLENGE');
55
+ expect(adif).toMatch(/<app_tx5dr_contest_entry:\d+>\{"schemaVersion":1/);
56
+ expect(parseADIFRecord(adif, 'test')).toMatchObject({
57
+ contestId: contestEntry.contestId,
58
+ contestEntry,
59
+ });
60
+ });
61
+ it('parses external Unicode contest envelopes using ADIF byte lengths', () => {
62
+ const contestEntry = {
63
+ schemaVersion: 1,
64
+ contestId: 'FT-CHALLENGE',
65
+ editionId: '2026-weekend-1',
66
+ rulesetVersion: '2026.1',
67
+ sent: { grid: 'PL05', operatorNote: '台北' },
68
+ received: { grid: 'PM96', snr: '-12' },
69
+ };
70
+ const rawEnvelope = JSON.stringify(contestEntry);
71
+ const adif = '<CALL:6>N0CALL<QSO_DATE:8>20260417<TIME_ON:6>120000<MODE:3>FT8<FREQ:9>14.074000'
72
+ + '<CONTEST_ID:12>FT-CHALLENGE'
73
+ + `<APP_TX5DR_CONTEST_ENTRY:${Buffer.byteLength(rawEnvelope)}>${rawEnvelope}<EOR>`;
74
+ expect(parseADIFRecord(adif, 'test')).toMatchObject({ contestEntry });
75
+ });
76
+ it('round-trips Unicode QTH and notes with UTF-8 byte lengths', () => {
77
+ const adif = convertQSOToADIF(createQso({ qth: '台北', notes: '測試' }));
78
+ expect(adif).toContain('<qth:6>台北');
79
+ expect(adif).toContain('<notes:6>測試');
80
+ expect(parseADIFRecord(adif, 'test')).toMatchObject({ qth: '台北', notes: '測試' });
81
+ });
82
+ it('does not attach a private contest envelope that conflicts with CONTEST_ID', () => {
83
+ const encoded = convertQSOToADIF(createQso({
84
+ contestId: 'FT-CHALLENGE',
85
+ contestEntry: {
86
+ schemaVersion: 1,
87
+ contestId: 'FT-CHALLENGE',
88
+ editionId: '2026-weekend-1',
89
+ rulesetVersion: '2026.1',
90
+ sent: {},
91
+ received: {},
92
+ },
93
+ })).replace('FT-CHALLENGE', 'WW-DIGI ');
94
+ const parsed = parseADIFRecord(encoded, 'test');
95
+ expect(parsed?.contestId).toBe('WW-DIGI ');
96
+ expect(parsed?.contestEntry).toBeUndefined();
97
+ });
98
+ });
99
+ describe('ADIF QSO comments', () => {
100
+ it('exports WSJT-X compatible signal reports in COMMENT', () => {
101
+ const adif = convertQSOToADIF(createQso({
102
+ mode: 'FT8',
103
+ submode: undefined,
104
+ reportSent: '-12',
105
+ reportReceived: '-09',
106
+ }));
107
+ expect(adif).toMatch(/<comment:\d+>FT8 {2}Sent: -12 {2}Rcvd: -09/);
108
+ });
109
+ it('keeps operator comments after the signal report COMMENT prefix', () => {
110
+ const adif = convertQSOToADIF(createQso({
111
+ mode: 'FT8',
112
+ submode: undefined,
113
+ reportSent: '-12',
114
+ reportReceived: '-09',
115
+ comment: 'TU',
116
+ }));
117
+ expect(adif).toMatch(/<comment:\d+>FT8 {2}Sent: -12 {2}Rcvd: -09 \| TU/);
118
+ });
119
+ it('stores message history in a TX-5DR private field instead of COMMENT', () => {
120
+ const adif = convertQSOToADIF(createQso({
121
+ mode: 'FT8',
122
+ submode: undefined,
123
+ reportSent: undefined,
124
+ reportReceived: undefined,
125
+ messageHistory: ['CQ TEST', 'RR73'],
126
+ }));
127
+ expect(adif).toMatch(/<app_tx5dr_message_history:\d+>CQ TEST \| RR73/);
128
+ expect(adif).not.toMatch(/<comment:\d+>CQ TEST/);
129
+ });
130
+ it('parses private message history and legacy COMMENT history', () => {
131
+ const parsedPrivate = parseADIFRecord('<call:6>N0CALL<qso_date:8>20260417<time_on:6>120000<mode:3>FT8<freq:9>14.074000<comment:25>FT8 Sent: -12 Rcvd: -09<app_tx5dr_message_history:14>CQ TEST | RR73<eor>', 'test');
132
+ const parsedLegacy = parseADIFRecord('<call:6>N0CALL<qso_date:8>20260417<time_on:6>120000<mode:3>FT8<freq:9>14.074000<comment:14>CQ TEST | RR73<eor>', 'test');
133
+ expect(parsedPrivate?.comment).toBe('FT8 Sent: -12 Rcvd: -09');
134
+ expect(parsedPrivate?.messageHistory).toEqual(['CQ TEST', 'RR73']);
135
+ expect(parsedLegacy?.comment).toBe('CQ TEST | RR73');
136
+ expect(parsedLegacy?.messageHistory).toEqual(['CQ TEST', 'RR73']);
137
+ });
31
138
  });
32
139
  //# sourceMappingURL=adif.test.js.map