@ham2k/extension-sdk 0.5.6 → 0.5.7
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/AGENTS.md +9 -9
- package/README.md +6 -6
- package/dist/activityExports.js +38 -23
- package/dist/activityScoring.js +3 -9
- package/dist/exportNames.js +16 -15
- package/dist/exportSettings.js +11 -1
- package/dist/index.d.ts +2 -2
- package/dist/scoring.js +4 -20
- package/docs/distribution.md +3 -3
- package/docs/hooks.md +5 -4
- package/package.json +1 -1
- package/samples/k2hrc-cqww/build.mjs +3 -3
- package/samples/k2hrc-cqww/src/index.ts +6 -15
- package/samples/k2hrc-hamqth/build.mjs +3 -3
- package/samples/k2hrc-hamqth/src/index.ts +4 -8
- package/samples/k2hrc-llota/build.mjs +3 -3
- package/samples/k2hrc-llota/src/index.ts +4 -7
- package/samples/k2hrc-radio/build.mjs +3 -3
- package/samples/k2hrc-radio/src/index.ts +1 -5
- package/samples/k2hrc-svg-scenes/build.mjs +3 -3
- package/samples/k2hrc-svg-scenes/manifest.json +2 -10
- package/samples/k2hrc-svg-scenes/src/index.ts +4 -4
- package/samples/k2hrc-svg-scenes/src/scenes.ts +195 -80
package/AGENTS.md
CHANGED
|
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
|
|
|
34
34
|
## The smallest whole extension
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
|
-
import { defineExtension } from
|
|
38
|
-
import type { PanelHook } from
|
|
37
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
38
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
39
39
|
|
|
40
|
-
import manifest from
|
|
40
|
+
import manifest from "../manifest.json"
|
|
41
41
|
|
|
42
42
|
const HelloPanel: PanelHook = {
|
|
43
43
|
async getPanels() {
|
|
44
|
-
return [{ key:
|
|
44
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
45
45
|
},
|
|
46
46
|
async render() {
|
|
47
|
-
return { kind:
|
|
47
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
defineExtension({
|
|
52
52
|
...manifest,
|
|
53
53
|
onActivation({ registerHook }) {
|
|
54
|
-
registerHook(
|
|
54
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
55
55
|
},
|
|
56
56
|
})
|
|
57
57
|
```
|
|
@@ -100,7 +100,7 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
|
|
|
100
100
|
evaluating one script. Reach the outside world through `host`:
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
|
-
import { host } from
|
|
103
|
+
import { host } from "@ham2k/extension-sdk"
|
|
104
104
|
|
|
105
105
|
await host.fetch(url) // https only, and only domains your manifest lists
|
|
106
106
|
await host.kvGet('key') // storage, namespaced to your extension
|
|
@@ -114,7 +114,7 @@ host.log('…') // the app's console
|
|
|
114
114
|
|
|
115
115
|
## Traps worth knowing before you write anything
|
|
116
116
|
|
|
117
|
-
- **A panel returns content, not widgets.** `{ kind:
|
|
117
|
+
- **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
|
|
118
118
|
its siblings. There is no way to reach the app's UI toolkit, by design.
|
|
119
119
|
- **`host.fetch` only reaches domains your manifest's `domains` lists**, over
|
|
120
120
|
https. An undeclared host is refused at the call, not at build.
|
|
@@ -122,7 +122,7 @@ host.log('…') // the app's console
|
|
|
122
122
|
carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
|
|
123
123
|
`sharedDependencies` rewrites your imports into lookups on the app's single
|
|
124
124
|
copy, which is worth roughly thirty times the bundle size. Don't install them.
|
|
125
|
-
`inline: [
|
|
125
|
+
`inline: ["liquidjs"]` in your build ships your own copy, for when you need a
|
|
126
126
|
version the app does not carry. A scaffolded manifest already declares the
|
|
127
127
|
eight this SDK's own modules reach — importing anything from it pulls them
|
|
128
128
|
in — so deleting one from the manifest breaks a build that was working.
|
package/README.md
CHANGED
|
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
|
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
```ts
|
|
12
|
-
import { defineExtension } from
|
|
13
|
-
import type { PanelHook } from
|
|
12
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
13
|
+
import type { PanelHook } from "@ham2k/extension-sdk"
|
|
14
14
|
|
|
15
|
-
import manifest from
|
|
15
|
+
import manifest from "../manifest.json"
|
|
16
16
|
|
|
17
17
|
const HelloPanel: PanelHook = {
|
|
18
18
|
async getPanels() {
|
|
19
|
-
return [{ key:
|
|
19
|
+
return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
|
|
20
20
|
},
|
|
21
21
|
async render() {
|
|
22
|
-
return { kind:
|
|
22
|
+
return { kind: "markdown", content: "# Hello, World!" }
|
|
23
23
|
},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
defineExtension({
|
|
27
27
|
...manifest,
|
|
28
28
|
onActivation({ registerHook }) {
|
|
29
|
-
registerHook(
|
|
29
|
+
registerHook("panel", { key: manifest.key, hook: HelloPanel })
|
|
30
30
|
},
|
|
31
31
|
})
|
|
32
32
|
```
|
package/dist/activityExports.js
CHANGED
|
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
|
|
|
23
23
|
const prefix = `${rules.key}-adif`;
|
|
24
24
|
return {
|
|
25
25
|
async getExportTypes() {
|
|
26
|
-
return [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
...exportTypeDefinition(rules.activationType, "adif", rules.label),
|
|
29
|
+
templateCategory: "reference",
|
|
30
|
+
templateSample: rules.templateSample ?? {
|
|
31
|
+
log: { ref: "REF-1234", refName: "Example Reference" },
|
|
32
|
+
operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
|
|
33
|
+
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
];
|
|
34
36
|
},
|
|
35
37
|
async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
|
|
36
38
|
const refs = refsOfType(operation, rules.activationType);
|
|
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
|
|
|
113
115
|
if (refsOfType(operation, rules.activationType).length > 0) return [];
|
|
114
116
|
if (huntedQsos(qsos ?? []).length === 0) return [];
|
|
115
117
|
const t = tFor(ctx);
|
|
116
|
-
return [
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
exportType,
|
|
121
|
+
qsoCount: huntedQsos(qsos ?? []).length,
|
|
122
|
+
templateData: { activity: rules.label },
|
|
123
|
+
format: "adif",
|
|
124
|
+
label: t("adifForHunter", { program: rules.label }),
|
|
125
|
+
filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
|
|
126
|
+
icon: rules.icon,
|
|
127
|
+
color: rules.color,
|
|
128
|
+
selectedByDefault: true
|
|
129
|
+
// No `refType`: hunted refs live on QSOs, not on operation segments,
|
|
130
|
+
// so this isn't the segment-scoped slice `activityExportHook` uses —
|
|
131
|
+
// it spans the whole log. (See `HuntingExportRules.color` for why the
|
|
132
|
+
// accent has to be threaded explicitly instead.)
|
|
133
|
+
}
|
|
134
|
+
];
|
|
131
135
|
},
|
|
132
136
|
async generateExport(args, ctx) {
|
|
133
137
|
const requested = String(args.exportType ?? "");
|
|
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
|
|
|
163
167
|
}
|
|
164
168
|
};
|
|
165
169
|
}
|
|
166
|
-
async function adifForExport({
|
|
170
|
+
async function adifForExport({
|
|
171
|
+
operation,
|
|
172
|
+
qsos,
|
|
173
|
+
segments,
|
|
174
|
+
includePrivateData,
|
|
175
|
+
includeLookupData,
|
|
176
|
+
exportSettings,
|
|
177
|
+
exportData,
|
|
178
|
+
exportTitle,
|
|
179
|
+
mainHandler,
|
|
180
|
+
includeFieldsFrom
|
|
181
|
+
}) {
|
|
167
182
|
const { hooks } = await import("@ham2k/extension-sdk");
|
|
168
183
|
const entries = await hooks.invokeOne("export", "adif", "generateExport", {
|
|
169
184
|
operation,
|
package/dist/activityScoring.js
CHANGED
|
@@ -62,9 +62,7 @@ function activityScorer(rules) {
|
|
|
62
62
|
if (candidates.length === 0) notice ?? (notice = "mode");
|
|
63
63
|
}
|
|
64
64
|
if (unique.has("ref")) {
|
|
65
|
-
candidates = candidates.filter(
|
|
66
|
-
(c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
|
|
67
|
-
);
|
|
65
|
+
candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
|
|
68
66
|
if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
|
|
69
67
|
}
|
|
70
68
|
if (notice) return notice;
|
|
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
|
|
|
189
187
|
const detail = refKeys.map(
|
|
190
188
|
(key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
|
|
191
189
|
);
|
|
192
|
-
const parts = [
|
|
193
|
-
qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
|
|
194
|
-
];
|
|
190
|
+
const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
|
|
195
191
|
if (huntedQsos > 0) {
|
|
196
192
|
const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
|
|
197
|
-
parts.push(
|
|
198
|
-
huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
|
|
199
|
-
);
|
|
193
|
+
parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
|
|
200
194
|
}
|
|
201
195
|
return {
|
|
202
196
|
key: "activation",
|
package/dist/exportNames.js
CHANGED
|
@@ -26,22 +26,23 @@ function exportFilename(parts) {
|
|
|
26
26
|
const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
|
|
27
27
|
const extension = parts.extension.replace(/^\./, "");
|
|
28
28
|
const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
|
|
29
|
-
const modifier = [
|
|
30
|
-
(parts.modifier ?? "").trim(),
|
|
31
|
-
isTestOperation(parts.stationCall) ? "Testing" : ""
|
|
32
|
-
].filter((part) => part !== "").join(" ");
|
|
29
|
+
const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
|
|
33
30
|
const stem = oneLine(
|
|
34
|
-
renderTemplate(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
renderTemplate(
|
|
32
|
+
NAME_TEMPLATES[shape],
|
|
33
|
+
{
|
|
34
|
+
op: dateValues(millis),
|
|
35
|
+
log: logValues({
|
|
36
|
+
station: String(parts.stationCall ?? "").trim(),
|
|
37
|
+
ref: parts.ref,
|
|
38
|
+
activity: parts.activity,
|
|
39
|
+
modifier,
|
|
40
|
+
extension,
|
|
41
|
+
compact: parts.compact
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
shape
|
|
45
|
+
)
|
|
45
46
|
);
|
|
46
47
|
return `${sanitize(stem) || "export"}.${extension}`;
|
|
47
48
|
}
|
package/dist/exportSettings.js
CHANGED
|
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
|
|
|
118
118
|
const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
|
|
119
119
|
const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
|
|
120
120
|
const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
|
|
121
|
-
const titleContext = settings.includePrivateData === false ? {
|
|
121
|
+
const titleContext = settings.includePrivateData === false ? {
|
|
122
|
+
...context,
|
|
123
|
+
op: {
|
|
124
|
+
...context.op,
|
|
125
|
+
title: "",
|
|
126
|
+
userTitle: "",
|
|
127
|
+
userNotes: "",
|
|
128
|
+
grid: String(context.op.grid ?? "").slice(0, 6)
|
|
129
|
+
},
|
|
130
|
+
log: { ...context.log, modifier: "" }
|
|
131
|
+
} : context;
|
|
122
132
|
const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
|
|
123
133
|
return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
|
|
124
134
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.5.
|
|
1
|
+
// @ham2k/extension-sdk 0.5.7
|
|
2
2
|
/** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
|
|
3
3
|
export interface SvgScene {
|
|
4
4
|
version: 1;
|
|
@@ -1527,7 +1527,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
|
|
|
1527
1527
|
suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
|
|
1528
1528
|
generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
|
|
1529
1529
|
};
|
|
1530
|
-
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
|
|
1530
|
+
export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
|
|
1531
1531
|
operation: Record<string, JSONValue>;
|
|
1532
1532
|
qsos: Record<string, JSONValue>[];
|
|
1533
1533
|
segments?: OperationSegmentPayload[];
|
package/dist/scoring.js
CHANGED
|
@@ -74,18 +74,8 @@ function contestScorer(scorer, options = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
|
|
76
76
|
const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
|
|
77
|
-
const rest = chronological(log).filter(
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
return fold(
|
|
81
|
-
scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
|
|
82
|
-
rest,
|
|
83
|
-
args,
|
|
84
|
-
ctx,
|
|
85
|
-
void 0,
|
|
86
|
-
void 0,
|
|
87
|
-
args.segments
|
|
88
|
-
);
|
|
77
|
+
const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
|
|
78
|
+
return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
|
|
89
79
|
}
|
|
90
80
|
return {
|
|
91
81
|
scope,
|
|
@@ -116,10 +106,7 @@ function contestScorer(scorer, options = {}) {
|
|
|
116
106
|
return {
|
|
117
107
|
qsoScores,
|
|
118
108
|
daySections,
|
|
119
|
-
operationSummary: scorer.summarizeScore(
|
|
120
|
-
{ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
|
|
121
|
-
ctx
|
|
122
|
-
),
|
|
109
|
+
operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
|
|
123
110
|
scoresheet: end,
|
|
124
111
|
...endDay !== void 0 && { scoresheetDay: endDay }
|
|
125
112
|
};
|
|
@@ -129,10 +116,7 @@ function contestScorer(scorer, options = {}) {
|
|
|
129
116
|
/// discarded, which is exactly why one `scoreQso` can serve both paths.
|
|
130
117
|
async scoreQso(args, ctx) {
|
|
131
118
|
const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
|
|
132
|
-
const { score } = scorer.scoreQso(
|
|
133
|
-
{ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false },
|
|
134
|
-
ctx
|
|
135
|
-
);
|
|
119
|
+
const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
|
|
136
120
|
return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
|
|
137
121
|
},
|
|
138
122
|
/// The same live judgement as `scoreQso`, for many candidates at once —
|
package/docs/distribution.md
CHANGED
|
@@ -198,8 +198,8 @@ below — manifest, source, build script — so the rest of this section is what
|
|
|
198
198
|
change rather than what to type.
|
|
199
199
|
|
|
200
200
|
```js
|
|
201
|
-
import { build } from
|
|
202
|
-
import { buildExtension } from
|
|
201
|
+
import { build } from "esbuild"
|
|
202
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
203
203
|
|
|
204
204
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
205
205
|
```
|
|
@@ -224,7 +224,7 @@ own module state — and nothing anywhere that says so. If the second copy is
|
|
|
224
224
|
what you want, which is how you use a version the host does not carry, say so:
|
|
225
225
|
|
|
226
226
|
```js
|
|
227
|
-
await buildExtension(build, { dir: import.meta.dirname, inline: [
|
|
227
|
+
await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
|
|
228
228
|
```
|
|
229
229
|
|
|
230
230
|
`extensions/samples/hello-world` is the shortest complete example. Declaring
|
package/docs/hooks.md
CHANGED
|
@@ -1197,10 +1197,11 @@ checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
|
|
|
1197
1197
|
the harness copies the checkpoint, replays that tail onto it (continuing the
|
|
1198
1198
|
day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
|
|
1199
1199
|
scores the candidate against the result. The host sends a checkpoint only when
|
|
1200
|
-
it can prove the log is what the checkpoint folded plus appended contacts
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1200
|
+
it can prove the log is what the checkpoint folded plus appended contacts;
|
|
1201
|
+
otherwise the harness reads the log through `ctx.getQsos` and folds it cold.
|
|
1202
|
+
The batched `scoreQsos` resumes the same way: after the first pass of a
|
|
1203
|
+
session, an append arrives as `resumeFrom` plus the appended contacts, and only
|
|
1204
|
+
their verdicts come back.
|
|
1204
1205
|
`scoreCandidates` answers the same question for many at once, which is what the
|
|
1205
1206
|
Spots Panel asks to mark the spots already worked: the log is folded once per
|
|
1206
1207
|
scorer and every candidate scored against a COPY of the result, so no candidate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -21,14 +21,7 @@
|
|
|
21
21
|
// for the whole thing.
|
|
22
22
|
|
|
23
23
|
import { annotateCallAgainstCountryFile, contestScorer, defineExtension } from "@ham2k/extension-sdk"
|
|
24
|
-
import type {
|
|
25
|
-
ContestScorer,
|
|
26
|
-
HookContext,
|
|
27
|
-
JSONValue,
|
|
28
|
-
LoggingControlDescriptor,
|
|
29
|
-
Ref,
|
|
30
|
-
ScoreTally,
|
|
31
|
-
} from "@ham2k/extension-sdk"
|
|
24
|
+
import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref, ScoreTally } from "@ham2k/extension-sdk"
|
|
32
25
|
import { fmtInteger } from "@ham2k/lib-format-tools"
|
|
33
26
|
|
|
34
27
|
import manifest from "../manifest.json" with { type: "json" }
|
|
@@ -53,7 +46,9 @@ function refOfType(container: Record<string, JSONValue>, type: string): Record<s
|
|
|
53
46
|
|
|
54
47
|
/// "05" and "5" are the same multiplier and must not count twice.
|
|
55
48
|
function normalizeZone(value: JSONValue | undefined): string {
|
|
56
|
-
const digits = String(value ?? "")
|
|
49
|
+
const digits = String(value ?? "")
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^0+(?=\d)/, "")
|
|
57
52
|
return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
|
|
58
53
|
}
|
|
59
54
|
|
|
@@ -154,10 +149,7 @@ const CQWWScorer: ContestScorer<Scoresheet> = {
|
|
|
154
149
|
|
|
155
150
|
const ActivityHook = {
|
|
156
151
|
/// What the operator fills in when adding this contest to an operation.
|
|
157
|
-
async operationControls(
|
|
158
|
-
_args: { operation: Record<string, JSONValue> },
|
|
159
|
-
_ctx: HookContext,
|
|
160
|
-
): Promise<LoggingControlDescriptor[]> {
|
|
152
|
+
async operationControls(_args: { operation: Record<string, JSONValue> }, _ctx: HookContext): Promise<LoggingControlDescriptor[]> {
|
|
161
153
|
return [
|
|
162
154
|
{
|
|
163
155
|
key: "k2hrc-cqww/setup",
|
|
@@ -209,8 +201,7 @@ const ActivityHook = {
|
|
|
209
201
|
const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
|
|
210
202
|
const guess = (their.guess as Record<string, JSONValue>) ?? {}
|
|
211
203
|
const call = str(their.call)
|
|
212
|
-
const suggested =
|
|
213
|
-
normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
204
|
+
const suggested = normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
|
|
214
205
|
|
|
215
206
|
return [
|
|
216
207
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -46,8 +46,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
46
46
|
const password = ctx.account?.credentials?.password
|
|
47
47
|
if (!username || !password) throw new Error("No HamQTH account configured")
|
|
48
48
|
|
|
49
|
-
const response = await host.fetch(
|
|
50
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
49
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
|
|
51
50
|
const sessionId = tag(response.body, "session_id")
|
|
52
51
|
if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
|
|
53
52
|
|
|
@@ -62,8 +61,7 @@ async function login(ctx: HookContext): Promise<string> {
|
|
|
62
61
|
/// Note that it re-logs-in only ONCE — a second failure is a real one, and
|
|
63
62
|
/// looping on it would hammer the API behind the operator's back.
|
|
64
63
|
async function fetchCall(call: string, ctx: HookContext): Promise<string> {
|
|
65
|
-
const search = (id: string) =>
|
|
66
|
-
host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
64
|
+
const search = (id: string) => host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
|
|
67
65
|
|
|
68
66
|
const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
|
|
69
67
|
const response = await search(sessionId)
|
|
@@ -167,13 +165,11 @@ const HamQTHAccount = {
|
|
|
167
165
|
if (!username || !credentials.password) return "Enter your HamQTH username and password"
|
|
168
166
|
|
|
169
167
|
try {
|
|
170
|
-
const response = await host.fetch(
|
|
171
|
-
`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
168
|
+
const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
|
|
172
169
|
const sessionId = tag(response.body, "session_id")
|
|
173
170
|
if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
|
|
174
171
|
|
|
175
|
-
const check = await host.fetch(
|
|
176
|
-
`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
172
|
+
const check = await host.fetch(`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
|
|
177
173
|
const name = tag(check.body, "adr_name")
|
|
178
174
|
return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
|
|
179
175
|
} catch (e) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -19,12 +19,7 @@
|
|
|
19
19
|
// API key the program issues, and the export hooks that submit a log. See
|
|
20
20
|
// `docs/hooks.md` §`spots` and §`export`.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
activityScorer,
|
|
24
|
-
contestScorer,
|
|
25
|
-
defineExtension,
|
|
26
|
-
referenceActivity,
|
|
27
|
-
} from "@ham2k/extension-sdk"
|
|
22
|
+
import { activityScorer, contestScorer, defineExtension, referenceActivity } from "@ham2k/extension-sdk"
|
|
28
23
|
import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
|
|
29
24
|
import { locationToGrid6 } from "@ham2k/lib-geo-tools"
|
|
30
25
|
|
|
@@ -116,7 +111,9 @@ const referenceList: DataFileDefinition = {
|
|
|
116
111
|
category: "k2hrc-llota",
|
|
117
112
|
|
|
118
113
|
jsonToLookupEntry: (entry: Record<string, any>) => {
|
|
119
|
-
const ref = String(entry?.reference_code ?? "")
|
|
114
|
+
const ref = String(entry?.reference_code ?? "")
|
|
115
|
+
.trim()
|
|
116
|
+
.toUpperCase()
|
|
120
117
|
// Returning null drops the entry. A list of this size will contain a few
|
|
121
118
|
// rows that are not references, and throwing would abandon the rest.
|
|
122
119
|
if (!ref) return null
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -49,11 +49,7 @@ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
|
49
49
|
/// document that does not parse, and the sandbox is a mitigation, not a reason
|
|
50
50
|
/// to skip this.
|
|
51
51
|
function escapeHtml(value: string): string {
|
|
52
|
-
return value
|
|
53
|
-
.replace(/&/g, "&")
|
|
54
|
-
.replace(/</g, "<")
|
|
55
|
-
.replace(/>/g, ">")
|
|
56
|
-
.replace(/"/g, """)
|
|
52
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
/// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { build } from
|
|
2
|
-
import { buildExtension } from
|
|
1
|
+
import { build } from "esbuild"
|
|
2
|
+
import { buildExtension } from "@ham2k/extension-tools"
|
|
3
3
|
|
|
4
4
|
await buildExtension(build, { dir: import.meta.dirname })
|
|
5
5
|
|
|
6
|
-
console.log(
|
|
6
|
+
console.log("built build/index.js")
|
|
@@ -8,16 +8,8 @@
|
|
|
8
8
|
"icon": "radio-tower",
|
|
9
9
|
"accentColor": "#4C6EF5",
|
|
10
10
|
"api": 1,
|
|
11
|
-
"keywords": [
|
|
12
|
-
|
|
13
|
-
"panel",
|
|
14
|
-
"sample",
|
|
15
|
-
"weather",
|
|
16
|
-
"radio"
|
|
17
|
-
],
|
|
18
|
-
"hooks": [
|
|
19
|
-
"panel"
|
|
20
|
-
],
|
|
11
|
+
"keywords": ["svg", "panel", "sample", "weather", "radio"],
|
|
12
|
+
"hooks": ["panel"],
|
|
21
13
|
"sharedDependencies": {
|
|
22
14
|
"@ham2k/lib-callsigns": "^1.0.0",
|
|
23
15
|
"@ham2k/lib-country-files": "^1.0.0",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import { defineExtension } from
|
|
4
|
-
import manifest from
|
|
5
|
-
import { createScenePanels } from
|
|
3
|
+
import { defineExtension } from "@ham2k/extension-sdk"
|
|
4
|
+
import manifest from "../manifest.json" with { type: "json" }
|
|
5
|
+
import { createScenePanels } from "./scenes.ts"
|
|
6
6
|
|
|
7
7
|
defineExtension({
|
|
8
8
|
...manifest,
|
|
9
9
|
onActivation({ registerHook }) {
|
|
10
|
-
registerHook(
|
|
10
|
+
registerHook("panel", { key: manifest.key, hook: createScenePanels() })
|
|
11
11
|
},
|
|
12
12
|
})
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from
|
|
3
|
+
import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
|
|
4
4
|
|
|
5
|
-
const svg = (body: string, width = 640, height = 360) =>
|
|
6
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
5
|
+
const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
|
|
7
6
|
const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
|
|
8
|
-
const label = (x: number, y: number, text: string, size = 16, color =
|
|
7
|
+
const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
|
|
9
8
|
`<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
|
|
10
|
-
const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14,
|
|
11
|
-
const textLayer = (id: string, value: string, x: number, y: number, width: number,
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, "#e7edf4")}`
|
|
10
|
+
const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): SvgSceneLayer => ({
|
|
11
|
+
id,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
width,
|
|
15
|
+
height: size + 12,
|
|
16
|
+
text: { value, prefix, suffix, decimals, size, color: "#a9efcf" },
|
|
17
|
+
})
|
|
14
18
|
|
|
15
19
|
type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
|
|
16
20
|
/** Inject time for deterministic tests; no timers or per-frame extension work. */
|
|
@@ -24,103 +28,214 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
|
|
|
24
28
|
s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
|
|
25
29
|
radios.set(id, s)
|
|
26
30
|
}
|
|
27
|
-
if (s.connected && s.due && now() >= s.due) {
|
|
31
|
+
if (s.connected && s.due && now() >= s.due) {
|
|
32
|
+
s.reported = s.requested
|
|
33
|
+
s.due = 0
|
|
34
|
+
}
|
|
28
35
|
return s
|
|
29
36
|
}
|
|
30
37
|
return {
|
|
31
38
|
async getPanels() {
|
|
32
39
|
return [
|
|
33
|
-
{ key:
|
|
34
|
-
{ key:
|
|
40
|
+
{ key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
|
|
41
|
+
{ key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
|
|
35
42
|
]
|
|
36
43
|
},
|
|
37
44
|
async render(args): Promise<PanelContent> {
|
|
38
|
-
if (args.panelKey ===
|
|
45
|
+
if (args.panelKey === "weather") {
|
|
39
46
|
const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
|
|
40
|
-
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(
|
|
41
|
-
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
|
|
48
|
+
return {
|
|
49
|
+
kind: "svgScene",
|
|
50
|
+
scene: {
|
|
51
|
+
version: 1,
|
|
52
|
+
width: 640,
|
|
53
|
+
height: 360,
|
|
54
|
+
values: { hour: 0 },
|
|
55
|
+
layers: [
|
|
56
|
+
{
|
|
57
|
+
id: "chart",
|
|
58
|
+
x: 0,
|
|
59
|
+
y: 0,
|
|
60
|
+
width: 640,
|
|
61
|
+
height: 360,
|
|
62
|
+
svg: svg(`
|
|
45
63
|
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
46
|
-
${label(30, 38,
|
|
47
|
-
${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join(
|
|
64
|
+
${label(30, 38, "FORECAST / SIMULATED DATA", 18, "#e7edf4")}
|
|
65
|
+
${[15, 20, 25].map((t) => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join("")}
|
|
48
66
|
<polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
|
|
49
|
-
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288,
|
|
50
|
-
${label(30, 334,
|
|
51
|
-
`)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, "+" + i + "h", 12)}`).join("")}
|
|
68
|
+
${label(30, 334, "Drag the chart to inspect · Arrow keys to step", 14)}
|
|
69
|
+
`),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "cursor",
|
|
73
|
+
x: 59,
|
|
74
|
+
y: 100,
|
|
75
|
+
width: 2,
|
|
76
|
+
height: 172,
|
|
77
|
+
svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
|
|
78
|
+
translateX: bind("hour", [0, 7], [0, 518]),
|
|
79
|
+
},
|
|
80
|
+
textLayer("hour", "hour", 30, 52, 250, 24, "In ", " hours"),
|
|
81
|
+
{
|
|
82
|
+
...textLayer("temperature", "hour", 370, 52, 240, 24, "", " °C"),
|
|
83
|
+
text: { value: "hour", samples: temperatures, suffix: " °C", size: 24, color: "#a9efcf" },
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
controls: [
|
|
87
|
+
{ id: "inspect", kind: "slider", label: "Forecast hour", x: 60, y: 100, width: 518, height: 185, value: "hour", min: 0, max: 7, step: 1 },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
62
91
|
}
|
|
63
92
|
const s = state(args.instanceId ?? args.panelKey)
|
|
64
|
-
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
return {
|
|
94
|
+
kind: "svgScene",
|
|
95
|
+
scene: {
|
|
96
|
+
version: 1,
|
|
97
|
+
width: 640,
|
|
98
|
+
height: 360,
|
|
99
|
+
values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
|
|
100
|
+
layers: [
|
|
101
|
+
{
|
|
102
|
+
id: "face",
|
|
103
|
+
x: 0,
|
|
104
|
+
y: 0,
|
|
105
|
+
width: 640,
|
|
106
|
+
height: 360,
|
|
107
|
+
svg: svg(`
|
|
69
108
|
<rect width="640" height="360" rx="18" fill="#142337"/>
|
|
70
|
-
${label(28, 35,
|
|
109
|
+
${label(28, 35, "RADIO FRONT FACE / SIMULATED CAT", 18, "#e7edf4")}
|
|
71
110
|
<rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
|
|
72
|
-
${label(42, 78,
|
|
111
|
+
${label(42, 78, "REPORTED VFO", 12)}${label(42, 143, "REQUESTED", 12)}
|
|
73
112
|
<path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
|
|
74
|
-
${label(423, 170,
|
|
75
|
-
${label(424, 197,
|
|
113
|
+
${label(423, 170, "14.0", 12)}${label(565, 170, "14.35", 12)}
|
|
114
|
+
${label(424, 197, "VFO POSITION", 12)}
|
|
76
115
|
<path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
|
|
77
|
-
${label(42, 268,
|
|
78
|
-
${label(548, 256,
|
|
79
|
-
${button(28,
|
|
80
|
-
`)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
${label(42, 268, "Band position / kHz", 14)}
|
|
117
|
+
${label(548, 256, "LINK", 12)}
|
|
118
|
+
${button(28, "Connect / disconnect")}${button(234, "External tune +5 kHz")}${button(440, "Toggle 2 s / 0 s delay")}
|
|
119
|
+
`),
|
|
120
|
+
},
|
|
121
|
+
textLayer("reported", "reported", 42, 78, 320, 34, "", " kHz", 1),
|
|
122
|
+
textLayer("requested", "requested", 168, 121, 210, 22, "", "", 1),
|
|
123
|
+
{
|
|
124
|
+
id: "needle",
|
|
125
|
+
x: 503,
|
|
126
|
+
y: 72,
|
|
127
|
+
width: 8,
|
|
128
|
+
height: 80,
|
|
129
|
+
pivot: [0.5, 1],
|
|
130
|
+
transitionMs: 450,
|
|
131
|
+
svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
|
|
132
|
+
rotation: bind("reported", [14000, 14350], [-80, 80]),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "knob",
|
|
136
|
+
x: 400,
|
|
137
|
+
y: 204,
|
|
138
|
+
width: 72,
|
|
139
|
+
height: 72,
|
|
140
|
+
svg: svg(
|
|
141
|
+
'<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>',
|
|
142
|
+
72,
|
|
143
|
+
72,
|
|
144
|
+
),
|
|
145
|
+
rotation: bind("requested", [14000, 14350], [-150, 150]),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "handle",
|
|
149
|
+
x: 36,
|
|
150
|
+
y: 224,
|
|
151
|
+
width: 12,
|
|
152
|
+
height: 24,
|
|
153
|
+
svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
|
|
154
|
+
translateX: bind("requested", [14000, 14350], [0, 290]),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "led",
|
|
158
|
+
x: 548,
|
|
159
|
+
y: 208,
|
|
160
|
+
width: 40,
|
|
161
|
+
height: 40,
|
|
162
|
+
svg: svg(
|
|
163
|
+
'<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>',
|
|
164
|
+
40,
|
|
165
|
+
40,
|
|
166
|
+
),
|
|
167
|
+
opacity: bind("connected", [0, 1], [0.08, 1]),
|
|
168
|
+
pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
controls: [
|
|
172
|
+
{
|
|
173
|
+
id: "tune",
|
|
174
|
+
label: "Tuning knob, kHz",
|
|
175
|
+
kind: "knob",
|
|
176
|
+
x: 398,
|
|
177
|
+
y: 202,
|
|
178
|
+
width: 76,
|
|
179
|
+
height: 76,
|
|
180
|
+
value: "requested",
|
|
181
|
+
min: 14000,
|
|
182
|
+
max: 14350,
|
|
183
|
+
step: 0.1,
|
|
184
|
+
sensitivity: 0.1,
|
|
185
|
+
event: "tune",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "band",
|
|
189
|
+
label: "Band position, kHz",
|
|
190
|
+
kind: "slider",
|
|
191
|
+
x: 42,
|
|
192
|
+
y: 215,
|
|
193
|
+
width: 290,
|
|
194
|
+
height: 42,
|
|
195
|
+
value: "requested",
|
|
196
|
+
min: 14000,
|
|
197
|
+
max: 14350,
|
|
198
|
+
step: 0.1,
|
|
199
|
+
event: "tune",
|
|
200
|
+
},
|
|
201
|
+
...(["connect", "external", "delay"] as const).map((event, i) => ({
|
|
202
|
+
id: event,
|
|
203
|
+
label: ["Toggle connection", "Simulate external tuning", "Toggle CAT delay"][i],
|
|
204
|
+
kind: "button" as const,
|
|
205
|
+
x: 28 + i * 206,
|
|
206
|
+
y: 298,
|
|
207
|
+
width: 172,
|
|
208
|
+
height: 38,
|
|
209
|
+
event,
|
|
210
|
+
})),
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
}
|
|
107
214
|
},
|
|
108
215
|
async onEvent(args) {
|
|
109
216
|
const s = state(args.instanceId ?? args.panelKey)
|
|
110
217
|
const event = args.event
|
|
111
|
-
if (event.action ===
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
218
|
+
if (event.action === "connect") {
|
|
219
|
+
s.connected = 1 - s.connected
|
|
220
|
+
s.due = 0
|
|
221
|
+
s.requested = s.reported
|
|
222
|
+
}
|
|
223
|
+
if (event.action === "delay") s.slow = 1 - s.slow
|
|
224
|
+
if (event.action === "external") {
|
|
225
|
+
if (!s.connected) throw new Error("Radio disconnected")
|
|
115
226
|
s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
|
|
116
|
-
s.requested = s.reported
|
|
227
|
+
s.requested = s.reported
|
|
228
|
+
s.due = 0
|
|
117
229
|
}
|
|
118
|
-
if (event.action ===
|
|
119
|
-
if (!s.connected) throw new Error(
|
|
120
|
-
if (typeof event.value !==
|
|
230
|
+
if (event.action === "tune") {
|
|
231
|
+
if (!s.connected) throw new Error("Radio disconnected")
|
|
232
|
+
if (typeof event.value !== "number" || !Number.isFinite(event.value)) throw new Error("Invalid frequency")
|
|
121
233
|
s.requested = Math.max(14000, Math.min(14350, event.value))
|
|
122
234
|
s.due = now() + (s.slow ? 2000 : 0)
|
|
123
|
-
if (!s.slow) {
|
|
235
|
+
if (!s.slow) {
|
|
236
|
+
s.reported = s.requested
|
|
237
|
+
s.due = 0
|
|
238
|
+
}
|
|
124
239
|
}
|
|
125
240
|
return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
|
|
126
241
|
},
|