create-tx5dr-plugin 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 +10 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +373 -62
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npx create-tx5dr-plugin [name] [options]
|
|
|
14
14
|
|------|-------------|---------|
|
|
15
15
|
| `--type <utility\|strategy>` | Plugin type | `utility` |
|
|
16
16
|
| `--lang <ts\|js>` | Language | `ts` |
|
|
17
|
-
| `--template <basic\|ui-vanilla\|ui-react\|ui-vue>` | Project template | `basic` |
|
|
17
|
+
| `--template <basic\|ui-vanilla\|ui-react\|ui-vue\|ft8-contest>` | Project template | `basic` |
|
|
18
18
|
| `--help, -h` | Show help | |
|
|
19
19
|
|
|
20
20
|
### Templates
|
|
@@ -25,6 +25,12 @@ npx create-tx5dr-plugin [name] [options]
|
|
|
25
25
|
| `ui-vanilla` | Plugin with vanilla HTML/JS/CSS UI page |
|
|
26
26
|
| `ui-react` | Plugin with React + Vite UI page |
|
|
27
27
|
| `ui-vue` | Plugin with Vue + Vite UI page |
|
|
28
|
+
| `ft8-contest` | TypeScript strategy with composable FT8/FT4 contest rules and tests |
|
|
29
|
+
|
|
30
|
+
`ft8-contest` fixes the type to `strategy` and language to `ts`; conflicting
|
|
31
|
+
explicit `--type` or `--lang` values are rejected. Its build emits declarations
|
|
32
|
+
with TypeScript and a self-contained ESM `dist/index.mjs` with esbuild, so the
|
|
33
|
+
Host can load the linked plugin directory without the project `node_modules`.
|
|
28
34
|
|
|
29
35
|
### Examples
|
|
30
36
|
|
|
@@ -46,6 +52,9 @@ npx create-tx5dr-plugin my-plugin --template ui-vue
|
|
|
46
52
|
|
|
47
53
|
# Vanilla UI (no build step for UI files)
|
|
48
54
|
npx create-tx5dr-plugin my-plugin --template ui-vanilla
|
|
55
|
+
|
|
56
|
+
# FT8 contest strategy with rule modules and test kit
|
|
57
|
+
npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
49
58
|
```
|
|
50
59
|
|
|
51
60
|
## Generated Structure
|
package/dist/index.d.ts
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
* npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
|
|
8
8
|
* npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
|
|
9
9
|
* npx create-tx5dr-plugin my-plugin --template ui-react
|
|
10
|
+
* npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
10
11
|
*/
|
|
11
12
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
* npx create-tx5dr-plugin my-plugin # Name only, prompts for rest
|
|
8
8
|
* npx create-tx5dr-plugin my-plugin --type utility # Non-interactive
|
|
9
9
|
* npx create-tx5dr-plugin my-plugin --template ui-react
|
|
10
|
+
* npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
10
11
|
*/
|
|
11
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
12
14
|
import { join, resolve } from 'node:path';
|
|
13
15
|
import { createInterface } from 'node:readline';
|
|
14
|
-
const
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const VALID_TEMPLATES = ['basic', 'ui-vanilla', 'ui-react', 'ui-vue', 'ft8-contest'];
|
|
15
18
|
// ===== CLI argument parsing =====
|
|
16
19
|
function parseArgs() {
|
|
17
20
|
const args = process.argv.slice(2);
|
|
@@ -58,7 +61,7 @@ function printUsage() {
|
|
|
58
61
|
Options:
|
|
59
62
|
--type <utility|strategy> Plugin type (default: utility)
|
|
60
63
|
--lang <ts|js> Language (default: ts)
|
|
61
|
-
--template <basic|ui-vanilla|ui-react|ui-vue>
|
|
64
|
+
--template <basic|ui-vanilla|ui-react|ui-vue|ft8-contest> Template (default: basic)
|
|
62
65
|
--help, -h Show this help message
|
|
63
66
|
|
|
64
67
|
Templates:
|
|
@@ -66,12 +69,14 @@ function printUsage() {
|
|
|
66
69
|
ui-vanilla Plugin with vanilla HTML/JS/CSS UI page
|
|
67
70
|
ui-react Plugin with React + Vite UI page
|
|
68
71
|
ui-vue Plugin with Vue + Vite UI page
|
|
72
|
+
ft8-contest TypeScript strategy with composable FT8 contest rules
|
|
69
73
|
|
|
70
74
|
Examples:
|
|
71
75
|
npx create-tx5dr-plugin my-plugin
|
|
72
76
|
npx create-tx5dr-plugin my-plugin --type strategy
|
|
73
77
|
npx create-tx5dr-plugin my-plugin --template ui-react
|
|
74
78
|
npx create-tx5dr-plugin my-plugin --template ui-vue --type utility
|
|
79
|
+
npx create-tx5dr-plugin my-contest --template ft8-contest
|
|
75
80
|
`);
|
|
76
81
|
}
|
|
77
82
|
// ===== Interactive prompts =====
|
|
@@ -88,21 +93,29 @@ async function promptConfig(partial) {
|
|
|
88
93
|
console.error('Plugin name is required.');
|
|
89
94
|
process.exit(1);
|
|
90
95
|
}
|
|
91
|
-
let
|
|
92
|
-
if (!
|
|
96
|
+
let template = partial.template;
|
|
97
|
+
if (!template) {
|
|
98
|
+
const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue/ft8-contest) [basic]: ');
|
|
99
|
+
template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
|
|
100
|
+
}
|
|
101
|
+
if (template === 'ft8-contest' && partial.type && partial.type !== 'strategy') {
|
|
102
|
+
console.error('The ft8-contest template requires --type strategy.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
if (template === 'ft8-contest' && partial.lang && partial.lang !== 'ts') {
|
|
106
|
+
console.error('The ft8-contest template requires --lang ts.');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
let type = template === 'ft8-contest' ? 'strategy' : partial.type ?? 'utility';
|
|
110
|
+
if (!partial.type && template !== 'ft8-contest') {
|
|
93
111
|
const answer = await prompt(rl, 'Plugin type (utility/strategy) [utility]: ');
|
|
94
112
|
type = answer === 'strategy' ? 'strategy' : 'utility';
|
|
95
113
|
}
|
|
96
|
-
let lang = partial.lang;
|
|
97
|
-
if (!lang) {
|
|
114
|
+
let lang = template === 'ft8-contest' ? 'ts' : partial.lang ?? 'ts';
|
|
115
|
+
if (!partial.lang && template !== 'ft8-contest') {
|
|
98
116
|
const answer = await prompt(rl, 'Language (ts/js) [ts]: ');
|
|
99
117
|
lang = answer === 'js' ? 'js' : 'ts';
|
|
100
118
|
}
|
|
101
|
-
let template = partial.template;
|
|
102
|
-
if (!template) {
|
|
103
|
-
const answer = await prompt(rl, 'Template (basic/ui-vanilla/ui-react/ui-vue) [basic]: ');
|
|
104
|
-
template = VALID_TEMPLATES.includes(answer) ? answer : 'basic';
|
|
105
|
-
}
|
|
106
119
|
return { name, type, lang, template };
|
|
107
120
|
}
|
|
108
121
|
finally {
|
|
@@ -111,19 +124,42 @@ async function promptConfig(partial) {
|
|
|
111
124
|
}
|
|
112
125
|
// ===== Helpers =====
|
|
113
126
|
function hasUI(config) {
|
|
114
|
-
return config.template
|
|
127
|
+
return config.template === 'ui-vanilla'
|
|
128
|
+
|| config.template === 'ui-react'
|
|
129
|
+
|| config.template === 'ui-vue';
|
|
115
130
|
}
|
|
116
131
|
function hasVite(config) {
|
|
117
132
|
return config.template === 'ui-react' || config.template === 'ui-vue';
|
|
118
133
|
}
|
|
134
|
+
function readContestLogbookUiAssets() {
|
|
135
|
+
const entry = require.resolve('@tx5dr/plugin-api/contest-logbook-ui/contest-log.html');
|
|
136
|
+
const root = resolve(entry, '..');
|
|
137
|
+
const files = new Map();
|
|
138
|
+
const visit = (directory, prefix) => {
|
|
139
|
+
for (const name of readdirSync(directory)) {
|
|
140
|
+
const source = join(directory, name);
|
|
141
|
+
const relative = join(prefix, name);
|
|
142
|
+
if (statSync(source).isDirectory())
|
|
143
|
+
visit(source, relative);
|
|
144
|
+
else
|
|
145
|
+
files.set(`ui/${relative}`, readFileSync(source, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
if (!existsSync(root))
|
|
149
|
+
throw new Error('contest-logbook-ui-assets-missing');
|
|
150
|
+
visit(root, '');
|
|
151
|
+
return files;
|
|
152
|
+
}
|
|
119
153
|
// ===== Core template generation =====
|
|
120
154
|
function generatePackageJson(config) {
|
|
121
155
|
const devDeps = {
|
|
122
|
-
'@tx5dr/plugin-api': '
|
|
156
|
+
'@tx5dr/plugin-api': '^2.5.0',
|
|
123
157
|
};
|
|
124
158
|
if (config.lang === 'ts') {
|
|
125
159
|
devDeps['typescript'] = '^5.0.0';
|
|
126
160
|
devDeps['vitest'] = '^1.0.0';
|
|
161
|
+
if (config.template === 'ft8-contest')
|
|
162
|
+
devDeps['esbuild'] = '^0.25.0';
|
|
127
163
|
}
|
|
128
164
|
if (hasVite(config)) {
|
|
129
165
|
devDeps['vite'] = '^6.0.0';
|
|
@@ -141,7 +177,14 @@ function generatePackageJson(config) {
|
|
|
141
177
|
}
|
|
142
178
|
const scripts = {};
|
|
143
179
|
if (config.lang === 'ts') {
|
|
144
|
-
if (
|
|
180
|
+
if (config.template === 'ft8-contest') {
|
|
181
|
+
scripts['build'] = 'npm run build:types && npm run build:bundle';
|
|
182
|
+
scripts['build:types'] = 'tsc --emitDeclarationOnly';
|
|
183
|
+
scripts['build:bundle'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs';
|
|
184
|
+
scripts['typecheck'] = 'tsc --noEmit';
|
|
185
|
+
scripts['dev'] = 'esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --legal-comments=none --outfile=dist/index.mjs --watch';
|
|
186
|
+
}
|
|
187
|
+
else if (hasVite(config)) {
|
|
145
188
|
scripts['build'] = 'tsc && npm run build:ui';
|
|
146
189
|
scripts['build:ui'] = 'vite build --config ui/vite.config.ts';
|
|
147
190
|
scripts['dev:server'] = 'tsc --watch';
|
|
@@ -159,11 +202,33 @@ function generatePackageJson(config) {
|
|
|
159
202
|
version: '0.1.0',
|
|
160
203
|
type: 'module',
|
|
161
204
|
...(config.lang === 'ts'
|
|
162
|
-
? {
|
|
205
|
+
? {
|
|
206
|
+
main: config.template === 'ft8-contest' ? 'dist/index.mjs' : 'dist/index.js',
|
|
207
|
+
types: 'dist/index.d.ts',
|
|
208
|
+
}
|
|
163
209
|
: { main: 'index.js' }),
|
|
164
210
|
scripts,
|
|
165
211
|
devDependencies: devDeps,
|
|
166
212
|
};
|
|
213
|
+
if (config.template === 'ft8-contest') {
|
|
214
|
+
pkg.tx5drPlugin = {
|
|
215
|
+
pluginName: config.name,
|
|
216
|
+
title: config.name,
|
|
217
|
+
description: `${config.name} FT8/FT4 contest strategy`,
|
|
218
|
+
minPluginApiVersion: '2.5.0',
|
|
219
|
+
author: 'TX-5DR plugin author',
|
|
220
|
+
license: 'GPL-3.0-only',
|
|
221
|
+
categories: ['contest', 'ft8', 'ft4'],
|
|
222
|
+
keywords: ['amateur-radio', 'contest', config.name],
|
|
223
|
+
entry: 'dist/index.mjs',
|
|
224
|
+
include: [
|
|
225
|
+
{ from: 'dist/index.mjs', to: 'index.mjs' },
|
|
226
|
+
{ from: 'ui', to: 'ui' },
|
|
227
|
+
{ from: 'src/locales', to: 'locales' },
|
|
228
|
+
{ from: 'README.md', to: 'README.md' },
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
167
232
|
return JSON.stringify(pkg, null, 2) + '\n';
|
|
168
233
|
}
|
|
169
234
|
function generateTsConfig() {
|
|
@@ -191,29 +256,42 @@ dist/
|
|
|
191
256
|
function generateLocaleZh(config) {
|
|
192
257
|
return JSON.stringify({
|
|
193
258
|
pluginDescription: `${config.name} plugin`,
|
|
259
|
+
contestLogTitle: '比赛日志',
|
|
260
|
+
contestNewCallsign: '本波段新台',
|
|
261
|
+
contestNewMultiplier: '新系数',
|
|
262
|
+
enabled: '启用自动发射控制',
|
|
263
|
+
enabledDesc: '允许此插件通过宿主发射协调器影响当前操作员的自动通联',
|
|
194
264
|
}, null, 2) + '\n';
|
|
195
265
|
}
|
|
196
266
|
function generateLocaleEn(config) {
|
|
197
267
|
return JSON.stringify({
|
|
198
268
|
pluginDescription: `${config.name} plugin`,
|
|
269
|
+
contestLogTitle: 'Contest log',
|
|
270
|
+
contestNewCallsign: 'New on band',
|
|
271
|
+
contestNewMultiplier: 'New multiplier',
|
|
272
|
+
enabled: 'Enable automatic transmit control',
|
|
273
|
+
enabledDesc: 'Allow this plugin to influence operator automation through the host coordinator',
|
|
199
274
|
}, null, 2) + '\n';
|
|
200
275
|
}
|
|
201
276
|
// ===== Server-side plugin definition templates =====
|
|
202
277
|
function generateTsUtilityPlugin(config) {
|
|
203
|
-
return `import
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
SlotInfo,
|
|
278
|
+
return `import {
|
|
279
|
+
definePlugin,
|
|
280
|
+
type ParsedFT8Message,
|
|
281
|
+
type SlotInfo,
|
|
208
282
|
} from '@tx5dr/plugin-api';
|
|
209
283
|
import zhLocale from './locales/zh.json' with { type: 'json' };
|
|
210
284
|
import enLocale from './locales/en.json' with { type: 'json' };
|
|
211
285
|
|
|
212
|
-
export const plugin
|
|
286
|
+
export const plugin = definePlugin({
|
|
287
|
+
apiVersion: 2,
|
|
213
288
|
name: '${config.name}',
|
|
214
289
|
version: '0.1.0',
|
|
215
290
|
type: 'utility',
|
|
216
291
|
description: 'pluginDescription',
|
|
292
|
+
// Add only the capabilities this plugin actually uses. The host omits all
|
|
293
|
+
// undeclared privileged APIs from both the TypeScript type and runtime context.
|
|
294
|
+
permissions: [],
|
|
217
295
|
|
|
218
296
|
settings: {
|
|
219
297
|
// Define your plugin settings here
|
|
@@ -227,18 +305,18 @@ export const plugin: PluginDefinition = {
|
|
|
227
305
|
},
|
|
228
306
|
|
|
229
307
|
hooks: {
|
|
230
|
-
onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx
|
|
308
|
+
onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx): void {
|
|
231
309
|
ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
|
|
232
310
|
},
|
|
233
311
|
|
|
234
|
-
onDecode(messages: ParsedFT8Message[], ctx
|
|
312
|
+
onDecode(messages: ParsedFT8Message[], ctx): void {
|
|
235
313
|
// Process decoded messages
|
|
236
314
|
for (const msg of messages) {
|
|
237
315
|
ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
|
|
238
316
|
}
|
|
239
317
|
},
|
|
240
318
|
},
|
|
241
|
-
};
|
|
319
|
+
});
|
|
242
320
|
|
|
243
321
|
export const locales: Record<string, Record<string, string>> = {
|
|
244
322
|
zh: zhLocale,
|
|
@@ -249,19 +327,20 @@ export default plugin;
|
|
|
249
327
|
`;
|
|
250
328
|
}
|
|
251
329
|
function generateTsStrategyPlugin(config) {
|
|
252
|
-
return `import
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
StrategyRuntime,
|
|
256
|
-
StrategyRuntimeSnapshot,
|
|
257
|
-
StrategyRuntimeSlot,
|
|
258
|
-
StrategyRuntimeSlotContentUpdate,
|
|
259
|
-
StrategyRuntimeContext,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
330
|
+
return `import {
|
|
331
|
+
definePlugin,
|
|
332
|
+
type StrategyPluginContext,
|
|
333
|
+
type StrategyRuntime,
|
|
334
|
+
type StrategyRuntimeSnapshot,
|
|
335
|
+
type StrategyRuntimeSlot,
|
|
336
|
+
type StrategyRuntimeSlotContentUpdate,
|
|
337
|
+
type StrategyRuntimeContext,
|
|
338
|
+
type StrategyRuntimeCheckpoint,
|
|
339
|
+
type ParsedFT8Message,
|
|
340
|
+
type StrategyDecisionResult,
|
|
341
|
+
type StrategyDecisionMetaV2,
|
|
342
|
+
type FrameMessage,
|
|
343
|
+
type SlotInfo,
|
|
265
344
|
} from '@tx5dr/plugin-api';
|
|
266
345
|
import zhLocale from './locales/zh.json' with { type: 'json' };
|
|
267
346
|
import enLocale from './locales/en.json' with { type: 'json' };
|
|
@@ -271,11 +350,32 @@ class PluginRuntime implements StrategyRuntime {
|
|
|
271
350
|
private slots: Partial<Record<StrategyRuntimeSlot, string>> = {};
|
|
272
351
|
private context: StrategyRuntimeContext = {};
|
|
273
352
|
|
|
274
|
-
constructor(private ctx:
|
|
353
|
+
constructor(private ctx: StrategyPluginContext) {}
|
|
354
|
+
|
|
355
|
+
checkpoint(): StrategyRuntimeCheckpoint {
|
|
356
|
+
return structuredClone({ state: this.state, slots: this.slots, context: this.context });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
restore(checkpoint: StrategyRuntimeCheckpoint): void {
|
|
360
|
+
const saved = checkpoint as {
|
|
361
|
+
state: StrategyRuntimeSlot;
|
|
362
|
+
slots: Partial<Record<StrategyRuntimeSlot, string>>;
|
|
363
|
+
context: StrategyRuntimeContext;
|
|
364
|
+
};
|
|
365
|
+
this.state = saved.state;
|
|
366
|
+
this.slots = { ...saved.slots };
|
|
367
|
+
this.context = { ...saved.context };
|
|
368
|
+
}
|
|
275
369
|
|
|
276
|
-
decide(messages: ParsedFT8Message[], meta
|
|
370
|
+
decide(messages: ParsedFT8Message[], meta: StrategyDecisionMetaV2): StrategyDecisionResult {
|
|
371
|
+
if (meta.signal.aborted) {
|
|
372
|
+
throw meta.signal.reason ?? new Error('Strategy decision aborted');
|
|
373
|
+
}
|
|
277
374
|
// Implement your QSO strategy logic here
|
|
278
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
transmission: this.getTransmitText(),
|
|
377
|
+
snapshot: this.getSnapshot(),
|
|
378
|
+
};
|
|
279
379
|
}
|
|
280
380
|
|
|
281
381
|
getTransmitText(): string | null {
|
|
@@ -317,15 +417,16 @@ class PluginRuntime implements StrategyRuntime {
|
|
|
317
417
|
}
|
|
318
418
|
}
|
|
319
419
|
|
|
320
|
-
export const plugin
|
|
420
|
+
export const plugin = definePlugin({
|
|
421
|
+
apiVersion: 2,
|
|
321
422
|
name: '${config.name}',
|
|
322
423
|
version: '0.1.0',
|
|
323
424
|
type: 'strategy',
|
|
324
425
|
description: 'pluginDescription',
|
|
426
|
+
// Selecting a strategy is the explicit grant for declarative RF decisions.
|
|
427
|
+
// Add operator:transmit-control only to utility plugins that submit commands.
|
|
325
428
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
createStrategyRuntime(ctx: PluginContext): StrategyRuntime {
|
|
429
|
+
createStrategyRuntime(ctx: StrategyPluginContext): StrategyRuntime {
|
|
329
430
|
return new PluginRuntime(ctx);
|
|
330
431
|
},
|
|
331
432
|
|
|
@@ -334,31 +435,183 @@ export const plugin: PluginDefinition = {
|
|
|
334
435
|
ctx.log.debug('Slot started', { slotId: slotInfo.id });
|
|
335
436
|
},
|
|
336
437
|
},
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
export const locales: Record<string, Record<string, string>> = {
|
|
441
|
+
zh: zhLocale,
|
|
442
|
+
en: enLocale,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export default plugin;
|
|
446
|
+
`;
|
|
447
|
+
}
|
|
448
|
+
function generateTsFT8ContestPlugin(config) {
|
|
449
|
+
return `import {
|
|
450
|
+
type FrameMessage,
|
|
451
|
+
type ParsedFT8Message,
|
|
452
|
+
type SlotInfo,
|
|
453
|
+
type StrategyDecisionMetaV2,
|
|
454
|
+
type StrategyDecisionResult,
|
|
455
|
+
type StrategyPluginContext,
|
|
456
|
+
type StrategyRuntime,
|
|
457
|
+
type StrategyRuntimeCheckpoint,
|
|
458
|
+
type StrategyRuntimeContext,
|
|
459
|
+
type StrategyRuntimeSlot,
|
|
460
|
+
type StrategyRuntimeSlotContentUpdate,
|
|
461
|
+
type StrategyRuntimeSnapshot,
|
|
462
|
+
} from '@tx5dr/plugin-api';
|
|
463
|
+
import {
|
|
464
|
+
cabrilloSubmission,
|
|
465
|
+
CONTEST_LOGBOOK_PERMISSIONS,
|
|
466
|
+
composeFT8ContestPlugin,
|
|
467
|
+
createContestQsoEnvelopeAdapter,
|
|
468
|
+
defineFT8Contest,
|
|
469
|
+
distancePoints,
|
|
470
|
+
fixedWeekendEdition,
|
|
471
|
+
gridExchange,
|
|
472
|
+
requireExchangeAndFinalAck,
|
|
473
|
+
type FT8ContestQso,
|
|
474
|
+
standardFT8ContestLogbook,
|
|
475
|
+
type GridExchange,
|
|
476
|
+
} from '@tx5dr/plugin-api/contest';
|
|
477
|
+
import zhLocale from './locales/zh.json' with { type: 'json' };
|
|
478
|
+
import enLocale from './locales/en.json' with { type: 'json' };
|
|
479
|
+
import jaLocale from './locales/ja.json' with { type: 'json' };
|
|
480
|
+
|
|
481
|
+
export type ContestQso = FT8ContestQso<GridExchange> & {
|
|
482
|
+
frequencyKhz: number;
|
|
483
|
+
cabrilloDateTime: string;
|
|
337
484
|
};
|
|
338
485
|
|
|
486
|
+
export const contest = defineFT8Contest<GridExchange, ContestQso>({
|
|
487
|
+
id: '${config.name}',
|
|
488
|
+
rulesetVersion: '2026.1',
|
|
489
|
+
edition: fixedWeekendEdition({
|
|
490
|
+
id: '2026',
|
|
491
|
+
// Replace these example boundaries and record the official rule source.
|
|
492
|
+
startAt: '2026-01-03T00:00:00Z',
|
|
493
|
+
endAt: '2026-01-04T00:00:00Z',
|
|
494
|
+
}),
|
|
495
|
+
bands: ['80M', '40M', '20M', '15M', '10M'],
|
|
496
|
+
exchange: gridExchange(),
|
|
497
|
+
// Completion is explicit because it belongs to the RF fail-closed boundary.
|
|
498
|
+
completion: requireExchangeAndFinalAck(),
|
|
499
|
+
scoring: distancePoints<ContestQso>({ stepKm: 3000 }),
|
|
500
|
+
submission: cabrilloSubmission<ContestQso>({
|
|
501
|
+
headers: () => [['CONTEST', '${config.name.toUpperCase()}']],
|
|
502
|
+
qsoLine: (qso) =>
|
|
503
|
+
\`QSO: \${qso.frequencyKhz} DG \${qso.cabrilloDateTime} \${qso.callsign}\`,
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
export const contestEnvelope = createContestQsoEnvelopeAdapter(contest);
|
|
508
|
+
|
|
509
|
+
export const logbook = standardFT8ContestLogbook({ contest });
|
|
510
|
+
|
|
511
|
+
class ContestRuntime implements StrategyRuntime {
|
|
512
|
+
private state: StrategyRuntimeSlot = 'TX6';
|
|
513
|
+
private slots: Partial<Record<StrategyRuntimeSlot, string>> = {};
|
|
514
|
+
private context: StrategyRuntimeContext = {};
|
|
515
|
+
|
|
516
|
+
constructor(private readonly ctx: StrategyPluginContext) {}
|
|
517
|
+
|
|
518
|
+
checkpoint(): StrategyRuntimeCheckpoint {
|
|
519
|
+
return structuredClone({ state: this.state, slots: this.slots, context: this.context });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
restore(checkpoint: StrategyRuntimeCheckpoint): void {
|
|
523
|
+
const saved = checkpoint as {
|
|
524
|
+
state: StrategyRuntimeSlot;
|
|
525
|
+
slots: Partial<Record<StrategyRuntimeSlot, string>>;
|
|
526
|
+
context: StrategyRuntimeContext;
|
|
527
|
+
};
|
|
528
|
+
this.state = saved.state;
|
|
529
|
+
this.slots = { ...saved.slots };
|
|
530
|
+
this.context = { ...saved.context };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
decide(messages: ParsedFT8Message[], meta: StrategyDecisionMetaV2): StrategyDecisionResult {
|
|
534
|
+
if (meta.signal.aborted) throw meta.signal.reason ?? new Error('Strategy decision aborted');
|
|
535
|
+
// Apply the contest exchange/completion modules in your protocol reducer here.
|
|
536
|
+
return { transmission: this.getTransmitText(), snapshot: this.getSnapshot() };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getTransmitText(): string | null {
|
|
540
|
+
return this.slots[this.state] ?? null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
requestCall(callsign: string, lastMessage?: { message: FrameMessage; slotInfo: SlotInfo }): void {
|
|
544
|
+
this.context.targetCallsign = callsign;
|
|
545
|
+
this.state = 'TX1';
|
|
546
|
+
this.ctx.log.info('Contest call requested', { callsign });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
getSnapshot(): StrategyRuntimeSnapshot {
|
|
550
|
+
return {
|
|
551
|
+
currentState: this.state,
|
|
552
|
+
slots: { ...this.slots },
|
|
553
|
+
context: { ...this.context },
|
|
554
|
+
availableSlots: ['TX1', 'TX2', 'TX3', 'TX4', 'TX5', 'TX6'],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
patchContext(patch: Partial<StrategyRuntimeContext>): void {
|
|
559
|
+
Object.assign(this.context, patch);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
setState(state: StrategyRuntimeSlot): void {
|
|
563
|
+
this.state = state;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
setSlotContent(update: StrategyRuntimeSlotContentUpdate): void {
|
|
567
|
+
this.slots[update.slot] = update.content;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
reset(reason?: string): void {
|
|
571
|
+
this.state = 'TX6';
|
|
572
|
+
this.slots = {};
|
|
573
|
+
this.context = {};
|
|
574
|
+
this.ctx.log.info('Contest strategy reset', { reason });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export const plugin = composeFT8ContestPlugin({
|
|
579
|
+
name: '${config.name}',
|
|
580
|
+
version: '0.1.0',
|
|
581
|
+
minPluginApiVersion: '2.5.0',
|
|
582
|
+
description: 'pluginDescription',
|
|
583
|
+
permissions: CONTEST_LOGBOOK_PERMISSIONS,
|
|
584
|
+
contest,
|
|
585
|
+
logbook,
|
|
586
|
+
runtime: (_contest, context) => new ContestRuntime(context),
|
|
587
|
+
});
|
|
588
|
+
|
|
339
589
|
export const locales: Record<string, Record<string, string>> = {
|
|
340
590
|
zh: zhLocale,
|
|
341
591
|
en: enLocale,
|
|
592
|
+
ja: jaLocale,
|
|
342
593
|
};
|
|
343
594
|
|
|
344
595
|
export default plugin;
|
|
345
596
|
`;
|
|
346
597
|
}
|
|
347
598
|
function generateTsUtilityPluginWithUI(config) {
|
|
348
|
-
return `import
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
SlotInfo,
|
|
599
|
+
return `import {
|
|
600
|
+
definePlugin,
|
|
601
|
+
type ParsedFT8Message,
|
|
602
|
+
type SlotInfo,
|
|
353
603
|
} from '@tx5dr/plugin-api';
|
|
354
604
|
import zhLocale from './locales/zh.json' with { type: 'json' };
|
|
355
605
|
import enLocale from './locales/en.json' with { type: 'json' };
|
|
356
606
|
|
|
357
|
-
export const plugin
|
|
607
|
+
export const plugin = definePlugin({
|
|
608
|
+
apiVersion: 2,
|
|
358
609
|
name: '${config.name}',
|
|
359
610
|
version: '0.1.0',
|
|
360
611
|
type: 'utility',
|
|
361
612
|
description: 'pluginDescription',
|
|
613
|
+
// Add only the capabilities this plugin actually uses.
|
|
614
|
+
permissions: [],
|
|
362
615
|
|
|
363
616
|
settings: {
|
|
364
617
|
// Define your plugin settings here
|
|
@@ -372,11 +625,12 @@ export const plugin: PluginDefinition = {
|
|
|
372
625
|
entry: 'settings.html',
|
|
373
626
|
title: 'settingsPage',
|
|
374
627
|
accessScope: 'admin',
|
|
628
|
+
resourceBinding: 'none',
|
|
375
629
|
},
|
|
376
630
|
],
|
|
377
631
|
},
|
|
378
632
|
|
|
379
|
-
onLoad(ctx
|
|
633
|
+
onLoad(ctx): void {
|
|
380
634
|
ctx.ui.registerPageHandler({
|
|
381
635
|
async onMessage(pageId, action, data) {
|
|
382
636
|
if (action === 'getSettings') {
|
|
@@ -396,17 +650,17 @@ export const plugin: PluginDefinition = {
|
|
|
396
650
|
},
|
|
397
651
|
|
|
398
652
|
hooks: {
|
|
399
|
-
onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx
|
|
653
|
+
onSlotStart(slotInfo: SlotInfo, messages: ParsedFT8Message[], ctx): void {
|
|
400
654
|
ctx.log.debug('Slot started', { slotId: slotInfo.id, messageCount: messages.length });
|
|
401
655
|
},
|
|
402
656
|
|
|
403
|
-
onDecode(messages: ParsedFT8Message[], ctx
|
|
657
|
+
onDecode(messages: ParsedFT8Message[], ctx): void {
|
|
404
658
|
for (const msg of messages) {
|
|
405
659
|
ctx.log.debug('Decoded message', { raw: msg.rawMessage, snr: msg.snr });
|
|
406
660
|
}
|
|
407
661
|
},
|
|
408
662
|
},
|
|
409
|
-
};
|
|
663
|
+
});
|
|
410
664
|
|
|
411
665
|
export const locales: Record<string, Record<string, string>> = {
|
|
412
666
|
zh: zhLocale,
|
|
@@ -417,12 +671,15 @@ export default plugin;
|
|
|
417
671
|
`;
|
|
418
672
|
}
|
|
419
673
|
function generateJsUtilityPlugin(config) {
|
|
420
|
-
return
|
|
421
|
-
|
|
674
|
+
return `import { definePlugin } from '@tx5dr/plugin-api';
|
|
675
|
+
|
|
676
|
+
export const plugin = definePlugin({
|
|
677
|
+
apiVersion: 2,
|
|
422
678
|
name: '${config.name}',
|
|
423
679
|
version: '0.1.0',
|
|
424
680
|
type: 'utility',
|
|
425
681
|
description: 'pluginDescription',
|
|
682
|
+
permissions: [],
|
|
426
683
|
|
|
427
684
|
settings: {
|
|
428
685
|
// Define your plugin settings here
|
|
@@ -439,18 +696,21 @@ export const plugin = {
|
|
|
439
696
|
}
|
|
440
697
|
},
|
|
441
698
|
},
|
|
442
|
-
};
|
|
699
|
+
});
|
|
443
700
|
|
|
444
701
|
export default plugin;
|
|
445
702
|
`;
|
|
446
703
|
}
|
|
447
704
|
function generateJsUtilityPluginWithUI(config) {
|
|
448
|
-
return
|
|
449
|
-
|
|
705
|
+
return `import { definePlugin } from '@tx5dr/plugin-api';
|
|
706
|
+
|
|
707
|
+
export const plugin = definePlugin({
|
|
708
|
+
apiVersion: 2,
|
|
450
709
|
name: '${config.name}',
|
|
451
710
|
version: '0.1.0',
|
|
452
711
|
type: 'utility',
|
|
453
712
|
description: 'pluginDescription',
|
|
713
|
+
permissions: [],
|
|
454
714
|
|
|
455
715
|
settings: {},
|
|
456
716
|
|
|
@@ -462,6 +722,7 @@ export const plugin = {
|
|
|
462
722
|
entry: 'settings.html',
|
|
463
723
|
title: 'settingsPage',
|
|
464
724
|
accessScope: 'admin',
|
|
725
|
+
resourceBinding: 'none',
|
|
465
726
|
},
|
|
466
727
|
],
|
|
467
728
|
},
|
|
@@ -495,13 +756,55 @@ export const plugin = {
|
|
|
495
756
|
}
|
|
496
757
|
},
|
|
497
758
|
},
|
|
498
|
-
};
|
|
759
|
+
});
|
|
499
760
|
|
|
500
761
|
export default plugin;
|
|
501
762
|
`;
|
|
502
763
|
}
|
|
503
764
|
// ===== Test templates =====
|
|
504
765
|
function generateTsTest(config) {
|
|
766
|
+
if (config.template === 'ft8-contest') {
|
|
767
|
+
return `import { describe, it, expect } from 'vitest';
|
|
768
|
+
import { createFT8ContestTestKit } from '@tx5dr/plugin-api/contest';
|
|
769
|
+
import { contest, contestEnvelope, plugin } from '../index.js';
|
|
770
|
+
|
|
771
|
+
describe('${config.name}', () => {
|
|
772
|
+
const kit = createFT8ContestTestKit(contest);
|
|
773
|
+
|
|
774
|
+
it('exposes an FT8 strategy plugin', () => {
|
|
775
|
+
expect(plugin.type).toBe('strategy');
|
|
776
|
+
expect(plugin.minPluginApiVersion).toBe('2.5.0');
|
|
777
|
+
expect(contest.operating).toMatchObject({
|
|
778
|
+
humanInitiation: 'required',
|
|
779
|
+
maxConcurrentQsos: 1,
|
|
780
|
+
maxSimultaneousSignals: 1,
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('round-trips the contest exchange', () => {
|
|
785
|
+
kit.exchange({ grid: 'FN31' }, { grid: 'FN31' });
|
|
786
|
+
kit.invalidExchange({ grid: 'ZZ99' }, 'invalid_grid');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('requires exchange and a final acknowledgement', () => {
|
|
790
|
+
kit.completion({
|
|
791
|
+
sentExchange: { grid: 'PL04' },
|
|
792
|
+
receivedExchange: { grid: 'FN31' },
|
|
793
|
+
receivedFinalAck: true,
|
|
794
|
+
}, true);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('creates an identity-bound contest QSO envelope', () => {
|
|
798
|
+
const envelope = contestEnvelope.create({
|
|
799
|
+
sent: { grid: 'PL04' },
|
|
800
|
+
received: { grid: 'FN31' },
|
|
801
|
+
});
|
|
802
|
+
expect(contestEnvelope.validate(envelope).ok).toBe(true);
|
|
803
|
+
expect(envelope.rulesetVersion).toBe(contest.rulesetVersion);
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
`;
|
|
807
|
+
}
|
|
505
808
|
if (config.type === 'strategy') {
|
|
506
809
|
return `import { describe, it, expect } from 'vitest';
|
|
507
810
|
import { createMockContext, createMockSlotInfo, createMockParsedMessage } from '@tx5dr/plugin-api/testing';
|
|
@@ -1066,7 +1369,13 @@ function generateFiles(config) {
|
|
|
1066
1369
|
files.set('.gitignore', generateGitignore());
|
|
1067
1370
|
if (config.lang === 'ts') {
|
|
1068
1371
|
files.set('tsconfig.json', generateTsConfig());
|
|
1069
|
-
if (config.
|
|
1372
|
+
if (config.template === 'ft8-contest') {
|
|
1373
|
+
files.set('src/index.ts', generateTsFT8ContestPlugin(config));
|
|
1374
|
+
for (const [relativePath, content] of readContestLogbookUiAssets())
|
|
1375
|
+
files.set(relativePath, content);
|
|
1376
|
+
files.set('README.md', `# ${config.name}\n\nFT8/FT4 contest strategy plugin for TX-5DR.\n`);
|
|
1377
|
+
}
|
|
1378
|
+
else if (config.type === 'strategy') {
|
|
1070
1379
|
files.set('src/index.ts', generateTsStrategyPlugin(config));
|
|
1071
1380
|
}
|
|
1072
1381
|
else if (hasUI(config)) {
|
|
@@ -1077,6 +1386,8 @@ function generateFiles(config) {
|
|
|
1077
1386
|
}
|
|
1078
1387
|
files.set('src/locales/zh.json', generateLocaleZh(config));
|
|
1079
1388
|
files.set('src/locales/en.json', generateLocaleEn(config));
|
|
1389
|
+
if (config.template === 'ft8-contest')
|
|
1390
|
+
files.set('src/locales/ja.json', JSON.stringify({ pluginDescription: `${config.name} plugin`, contestLogTitle: 'コンテストログ', contestNewCallsign: 'このバンドで未交信', contestNewMultiplier: '新マルチ' }, null, 2) + '\n');
|
|
1080
1391
|
files.set('src/__tests__/plugin.test.ts', generateTsTest(config));
|
|
1081
1392
|
}
|
|
1082
1393
|
else {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-tx5dr-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new TX-5DR plugin project",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/boybook/tx-5dr
|
|
9
|
+
"url": "https://github.com/boybook/tx-5dr",
|
|
10
10
|
"directory": "packages/create-tx5dr-plugin"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc",
|
|
25
|
-
"dev": "tsc --watch"
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"smoke:ft8-contest": "node scripts/ft8-contest-smoke.mjs"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@tx5dr/plugin-api": "^2.5.0"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
32
|
"typescript": "^5.0.0"
|