@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.
- package/README.md +475 -17
- package/dist/__tests__/adif.test.js +107 -0
- package/dist/__tests__/adif.test.js.map +1 -1
- package/dist/__tests__/capability-context.test.d.ts +2 -0
- package/dist/__tests__/capability-context.test.d.ts.map +1 -0
- package/dist/__tests__/capability-context.test.js +99 -0
- package/dist/__tests__/capability-context.test.js.map +1 -0
- package/dist/__tests__/testing-utils.test.js +234 -15
- package/dist/__tests__/testing-utils.test.js.map +1 -1
- package/dist/capabilities.d.ts +38 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +50 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/compatibility.d.ts +15 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +71 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
- package/dist/contest-logbook-ui/contest-log.html +13 -0
- package/dist/context.d.ts +125 -54
- package/dist/context.d.ts.map +1 -1
- package/dist/definition.d.ts +98 -21
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +8 -1
- package/dist/definition.js.map +1 -1
- package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
- package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
- package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
- package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
- package/dist/ft8/index.d.ts +22 -0
- package/dist/ft8/index.d.ts.map +1 -0
- package/dist/ft8/index.js +176 -0
- package/dist/ft8/index.js.map +1 -0
- package/dist/helpers.d.ts +417 -132
- package/dist/helpers.d.ts.map +1 -1
- package/dist/hooks.d.ts +56 -34
- package/dist/hooks.d.ts.map +1 -1
- package/dist/host-dependencies.d.ts +101 -0
- package/dist/host-dependencies.d.ts.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +539 -13
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -1
- package/dist/runtime.js.map +1 -1
- package/dist/settings.d.ts +31 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/simulation.d.ts +60 -0
- package/dist/simulation.d.ts.map +1 -0
- package/dist/simulation.js +2 -0
- package/dist/simulation.js.map +1 -0
- package/dist/sync.d.ts +98 -6
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +6 -0
- package/dist/sync.js.map +1 -1
- package/dist/testing/index.d.ts +74 -12
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +372 -105
- package/dist/testing/index.js.map +1 -1
- package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
- package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
- package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
- package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
- package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.js +845 -0
- package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
- package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
- package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.js +387 -0
- package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.js +247 -0
- package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
- package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
- package/dist/toolkit/contest/index.d.ts +14 -0
- package/dist/toolkit/contest/index.d.ts.map +1 -0
- package/dist/toolkit/contest/index.js +14 -0
- package/dist/toolkit/contest/index.js.map +1 -0
- package/dist/toolkit/index.d.ts +3 -0
- package/dist/toolkit/index.d.ts.map +1 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/index.js.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
- package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/index.d.ts +8 -0
- package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/index.js +8 -0
- package/dist/toolkit/parallel-qso/index.js.map +1 -0
- package/dist/utils/adif.d.ts +1 -1
- package/dist/utils/adif.d.ts.map +1 -1
- package/dist/utils/adif.js +96 -51
- package/dist/utils/adif.js.map +1 -1
- package/dist/utils/qso-text-fields.d.ts +7 -1
- package/dist/utils/qso-text-fields.d.ts.map +1 -1
- package/dist/utils/qso-text-fields.js +74 -6
- package/dist/utils/qso-text-fields.js.map +1 -1
- package/package.json +28 -6
- package/src/bridge.d.ts +3 -0
- 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
|
|
18
|
+
import { definePlugin } from '@tx5dr/plugin-api';
|
|
19
19
|
|
|
20
|
-
const plugin
|
|
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
|
-
|
|
40
|
-
|
|
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` |
|
|
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
|
-
|
|
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`
|
|
71
|
-
- `radio:control`
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
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
|
|
334
|
+
import { definePlugin } from '@tx5dr/plugin-api';
|
|
93
335
|
|
|
94
|
-
const plugin
|
|
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
|