@ham2k/extension-sdk 0.12.5 → 0.12.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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.5
1
+ // @ham2k/extension-sdk 0.12.7
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -556,6 +556,7 @@ export interface HookContext {
556
556
  getOperation?(uuid: string): Promise<Record<string, JSONValue> | null>;
557
557
  getQsos?(operationUuid: string): Promise<Record<string, JSONValue>[] | null>;
558
558
  getSpotsForCall?(call: string, maxAgeMinutes?: number): Promise<Spot[]>;
559
+ getSpots?(options?: GetSpotsOptions): Promise<BoardSpot[]>;
559
560
  getHistoryForCall?(call: string, options?: HistoryForCallOptions): Promise<Record<string, JSONValue>[]>;
560
561
  }
561
562
  export interface HistoryForCallOptions {
@@ -624,6 +625,24 @@ export interface Spot {
624
625
  sourceInfo?: Record<string, JSONValue>;
625
626
  };
626
627
  }
628
+ export interface GetSpotsOptions {
629
+ filter?: "user" | "all";
630
+ maxAgeMinutes?: number;
631
+ band?: string | string[];
632
+ mode?: string | string[];
633
+ source?: string | string[];
634
+ annotate?: boolean;
635
+ operationUuid?: string;
636
+ }
637
+ export interface BoardSpot extends Spot {
638
+ hookKey?: string;
639
+ annotations?: SpotAnnotations;
640
+ }
641
+ export interface SpotAnnotations {
642
+ worked: boolean;
643
+ isSelf: boolean;
644
+ notices: string[];
645
+ }
627
646
  export interface SpotEligibility {
628
647
  enabled: boolean;
629
648
  icon?: string;
package/docs/hooks.md CHANGED
@@ -70,6 +70,31 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
70
70
  reads above it is cheap enough for the lookup path; it resolves `[]`
71
71
  whenever the cache is cold or the host keeps no spots. Used by
72
72
  `spot-history` to pull a hunted park or summit into the QSO being logged.
73
+ * `getSpots?(options?)` — the whole spots board the host already holds,
74
+ for a panel showing spots its own way. Like `getSpotsForCall` it reads the
75
+ cache and never fetches, and resolves `[]` while the cache is cold. By
76
+ default it answers what the operator's Spots Panel shows: their sources,
77
+ enabled bands, band and mode filters (an "auto" filter follows the radio),
78
+ age and continents, merged and ordered as that panel does — so one spot
79
+ can stand for several stations, its `their.call` comma-joined
80
+ (`'K1ABC,W2XYZ'`). `filter: 'all'`
81
+ starts instead from every held spot, one per source report, newest first.
82
+ Either way `maxAgeMinutes`, `band`, `mode` (a mode, or `SSB`/`Phone`/`CW`/
83
+ `Digital` for a family) and `source` (a spot source's hook key, carried on
84
+ each spot as `hookKey`) narrow it further. Spots come back with
85
+ `annotations` — `worked`, `isSelf`, `notices`, judged the way the Spots
86
+ Panel strikes and accents its rows — whenever there is an operation to judge
87
+ them against: `operationUuid`, or the `operation` the calling hook was
88
+ handed, so a panel's render gets them without asking. The verdicts are the
89
+ ones the app already holds for the operation open on screen, so the call
90
+ never waits on a scorer; another operation's spots, and that one's until
91
+ its first verdicts land, come back unannotated rather than guessed.
92
+ `annotate: false` returns the spots as their sources gave them.
93
+ A panel's `'spots'` trigger fires whenever this answer may have moved: a
94
+ refresh, new verdicts, the operator's spot filters or bands, or the radio's
95
+ band or mode. A malformed or unknown option rejects the call. The CLI host
96
+ has no operator: its `'user'` board is every spot handed to it, never
97
+ annotated.
73
98
  * `getHistoryForCall?(call, options?)` — the most recent non-deleted contacts
74
99
  logged with `call` (five unless `limit` says otherwise, never more than
75
100
  50), across every operation, newest first, as raw QSON — the one log read
@@ -1975,7 +2000,9 @@ its first render.
1975
2000
 
1976
2001
  The whole log is deliberately absent from `render`'s args. A panel that
1977
2002
  needs it calls `ctx.getQsos(operation.uuid)` — right for a `'qsoLogged'`
1978
- render, wrong for a per-keystroke one.
2003
+ render, wrong for a per-keystroke one. Spots are absent for the same reason:
2004
+ a panel showing them declares `'spots'` and calls `ctx.getSpots()`, which
2005
+ answers the operator's own filtered board, judged against this operation.
1979
2006
 
1980
2007
  ## ✅ `settingsPanel` — extension settings forms
1981
2008
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
package/samples/README.md CHANGED
@@ -11,6 +11,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
11
11
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
12
12
  | `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
13
13
  | `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
14
+ | `k2hrc-panafall` | A radio's panadapter and waterfall in a scene, styled and tuned by click (extension API 6) |
14
15
 
15
16
  ## Running one
16
17
 
@@ -0,0 +1,16 @@
1
+ # Panadapter & Waterfall
2
+
3
+ The radio's panadapter and waterfall in a scene panel, with its span, reference
4
+ level and sweep speed, a local contrast control, and click-to-tune. The host
5
+ draws the sweeps in the `spectrum` control; they never reach the extension.
6
+
7
+ Needs a host with the panadapter enabled (`EXP FALL`) and a radio whose CAT
8
+ protocol carries its scope: an Icom IC-705/7300/7610 (at 115200 bps on a serial
9
+ line), or a FlexRadio, which is mirrored through a DAX IQ channel.
10
+
11
+ In this repository:
12
+
13
+ ```sh
14
+ node build.mjs
15
+ node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-panafall.h2kext
16
+ ```
@@ -0,0 +1,6 @@
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
+
4
+ await buildExtension(build, { dir: import.meta.dirname })
5
+
6
+ console.log("built build/index.js")
@@ -0,0 +1,24 @@
1
+ {
2
+ "key": "k2hrc-panafall",
3
+ "name": "Panadapter & Waterfall",
4
+ "shortName": "Panafall",
5
+ "version": "0.1.0",
6
+ "description": "The radio's panadapter and waterfall, with span, reference level and click-to-tune",
7
+ "category": "dashboard",
8
+ "icon": "waveform",
9
+ "accentColor": "#4C6EF5",
10
+ "api": 6,
11
+ "keywords": ["radio", "panadapter", "waterfall", "spectrum", "panel", "sample"],
12
+ "hooks": ["panel"],
13
+ "requiresRadioWrite": true,
14
+ "sharedDependencies": {
15
+ "@ham2k/lib-callsigns": "^1.0.0",
16
+ "@ham2k/lib-country-files": "^1.0.0",
17
+ "@ham2k/lib-dxcc-data": "^1.0.0",
18
+ "@ham2k/lib-format-tools": "^1.0.0",
19
+ "@ham2k/lib-geo-tools": "^1.0.0",
20
+ "@ham2k/lib-operation-data": "^1.0.0",
21
+ "i18next": "^23.0.0",
22
+ "liquidjs": "^10.0.0"
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ import { defineExtension } from "@ham2k/extension-sdk"
4
+ import manifest from "../manifest.json" with { type: "json" }
5
+ import { createPanafall } from "./panafall.ts"
6
+
7
+ defineExtension({
8
+ ...manifest,
9
+ onActivation({ registerHook }) {
10
+ registerHook("panel", { key: manifest.key, hook: createPanafall() })
11
+ },
12
+ })
@@ -0,0 +1,238 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // The radio's scope as a scene: the host draws the sweeps in the `spectrum`
5
+ // control, and this extension only places it and turns the radio's knobs.
6
+ import { host } from "@ham2k/extension-sdk"
7
+ import type { PanelContent, PanelHook, PanelRadioSpectrum, PanelRadioState, PanelScene } from "@ham2k/extension-sdk"
8
+
9
+ type Display = "both" | "pan" | "waterfall"
10
+
11
+ /** A slider with its label beside it, kept together when the wrap breaks. */
12
+ const labeled = (text: string, control: string) => ({
13
+ wrap: [{ control: `label-${text}` }, { control, width: 120 }],
14
+ spacing: 4,
15
+ crossAxisAlignment: "center" as const,
16
+ })
17
+
18
+ /** Where the receiver listens, from the tuned frequency, by mode. A sketch: the radio's real filter is not reported. */
19
+ function passband(mode: string | null | undefined): [number, number] | null {
20
+ if (!mode) return null
21
+ if (mode.includes("LSB")) return [-2800, -100]
22
+ if (mode.includes("USB") || mode.startsWith("DATA") || mode === "FT8" || mode === "FT4") return [100, 2800]
23
+ if (mode.startsWith("CW")) return [-250, 250]
24
+ if (mode === "AM" || mode === "FM") return [-3000, 3000]
25
+ return null
26
+ }
27
+
28
+ const cyan = "#2EC5F0"
29
+
30
+ /** Landmarks on 20 m: something to read, and to click to go to. */
31
+ const landmarks: [string, number, "start" | "center" | "end"][] = [
32
+ ["PSK", 14070000, "center"],
33
+ ["FT8", 14074000, "start"],
34
+ ["FT4", 14080000, "center"],
35
+ ["SSTV", 14230000, "start"],
36
+ ["QRP", 14285000, "end"],
37
+ ["Maritime net", 14300000, "center"],
38
+ ]
39
+
40
+ const spanLabel = (hz: number) => `±${hz >= 1000 ? `${hz / 1000} kHz` : `${hz} Hz`}`
41
+
42
+ export function scene(radio: PanelRadioState | null, display: Display, step = "100"): PanelScene {
43
+ const spectrum: PanelRadioSpectrum | null | undefined = radio?.spectrum
44
+ const knobs = spectrum && !spectrum.problem
45
+ const band = passband(radio?.mode)
46
+ return {
47
+ version: 1,
48
+ width: 600,
49
+ height: 24,
50
+ values: { floor: 0, average: 40, fps: 15, rows: 10, hoverHz: 0, hoverDb: 0, ref: spectrum?.refLevelDb ?? 0 },
51
+ strings: {
52
+ span: spectrum?.spanHz != null ? String(spectrum.spanHz) : "",
53
+ speed: spectrum?.speed ?? "",
54
+ display,
55
+ step,
56
+ },
57
+ layers: [
58
+ {
59
+ id: "readout",
60
+ x: 0,
61
+ y: 0,
62
+ width: 600,
63
+ height: 24,
64
+ text: { value: "hoverHz", scale: 0.001, decimals: 3, suffix: " kHz", size: 14 },
65
+ },
66
+ ],
67
+ controls: [
68
+ ...(knobs
69
+ ? [
70
+ {
71
+ id: "span",
72
+ label: "Span",
73
+ kind: "nativeDropdown" as const,
74
+ value: "span",
75
+ event: "span",
76
+ options: spectrum.spansHz.map((hz) => ({ label: spanLabel(hz), value: String(hz) })),
77
+ },
78
+ {
79
+ id: "ref",
80
+ label: "Reference level",
81
+ kind: "nativeSlider" as const,
82
+ value: "ref",
83
+ event: "ref",
84
+ min: spectrum.refMinDb,
85
+ max: spectrum.refMaxDb,
86
+ step: spectrum.refStepDb,
87
+ },
88
+ {
89
+ id: "speed",
90
+ label: "Speed",
91
+ kind: "nativeSegmented" as const,
92
+ value: "speed",
93
+ event: "speed",
94
+ options: spectrum.speeds.map((s) => ({ label: s, value: s })),
95
+ },
96
+ ]
97
+ : []),
98
+ { id: "floor", label: "Contrast", kind: "nativeSlider" as const, value: "floor", min: -30, max: 30, step: 1 },
99
+ { id: "average", label: "Averaging", kind: "nativeSlider" as const, value: "average", min: 0, max: 95, step: 5 },
100
+ { id: "fps", label: "Panadapter FPS", kind: "nativeSlider" as const, value: "fps", min: 1, max: 30, step: 1 },
101
+ { id: "rows", label: "Waterfall rows per second", kind: "nativeSlider" as const, value: "rows", min: 1, max: 30, step: 1 },
102
+ // A control the layout does not place fails the scene: Ref's label goes with its slider.
103
+ ...[...(knobs ? ["Ref"] : []), "Contrast", "Avg", "FPS", "Rows"].map((text) => ({
104
+ id: `label-${text}`,
105
+ label: text,
106
+ kind: "nativeText" as const,
107
+ })),
108
+ {
109
+ id: "step",
110
+ label: "Tuning step",
111
+ kind: "nativeSegmented" as const,
112
+ value: "step",
113
+ event: "step",
114
+ options: ["10", "100", "500", "1000"].map((hz) => ({ label: Number(hz) >= 1000 ? `${Number(hz) / 1000} kHz` : `${hz} Hz`, value: hz })),
115
+ },
116
+ {
117
+ id: "display",
118
+ label: "Display",
119
+ kind: "nativeSegmented" as const,
120
+ value: "display",
121
+ event: "display",
122
+ options: (["both", "pan", "waterfall"] as const).map((d) => ({ label: d, value: d })),
123
+ },
124
+ {
125
+ id: "spectrum",
126
+ label: "Panadapter",
127
+ kind: "spectrum" as const,
128
+ event: "tune",
129
+ display,
130
+ floor: "floor",
131
+ average: "average",
132
+ fps: "fps",
133
+ waterfallSpeed: "rows",
134
+ backgroundColor: "#0B0E17",
135
+ traceColor: "#3FD0F0",
136
+ fillColor: "#CC3FD0F0",
137
+ fillColorBottom: "#B3104A5C",
138
+ gridColor: "#2A3446",
139
+ gridDashed: true,
140
+ scale: {
141
+ gap: 26,
142
+ background: "#151B26",
143
+ borderTop: { color: "#2A3446", width: 1 },
144
+ borderBottom: { color: "#2A3446", width: 1 },
145
+ labels: { color: "#8FA3B8", size: 12, offset: 8 },
146
+ ticks: { color: "#8FA3B8", width: 1, offset: 0, length: 5 },
147
+ },
148
+ // Noise to black, as the radio's own display does: the waterfall's window sits above the panadapter's.
149
+ waterfallFloor: 12,
150
+ waterfallRange: 50,
151
+ waterfallColors: ["#000000", "#00004A", "#0000FF", "#00C8FF", "#00FF60", "#FFFF00", "#FF2000"],
152
+ center: { pan: { color: cyan, width: 4 }, waterfall: { color: cyan, width: 4 } },
153
+ tuneStep: Number(step),
154
+ annotations: landmarks.map(([text, hz, align]) => ({
155
+ id: text,
156
+ text,
157
+ hz,
158
+ align,
159
+ borderColor: "#FFB020",
160
+ pointer: { size: 5 },
161
+ event: "goto",
162
+ offset: 6,
163
+ color: "#0B0E17",
164
+ background: "#E6FFB020",
165
+ size: 11,
166
+ weight: 600,
167
+ })),
168
+ overlays: [
169
+ ...(band ? [{ from: band[0], to: band[1], pan: { fill: "#262EC5F0", color: "#992EC5F0" } }] : []),
170
+ // The bottom of 20 m, and what lies outside it: scenery behind the trace, over the waterfall.
171
+ { at: 14000000, anchor: "absolute" as const, pan: { color: "#FF3030", width: 3 }, waterfall: { color: "#FF3030", width: 3 } },
172
+ { from: 13900000, to: 14000000, anchor: "absolute" as const, pan: { fill: "#33FF3030" }, waterfall: { fill: "#33FF3030" } },
173
+ ],
174
+ hoverHz: "hoverHz",
175
+ hoverDb: "hoverDb",
176
+ },
177
+ ],
178
+ layout: {
179
+ column: [
180
+ {
181
+ wrap: [
182
+ ...(knobs ? [{ control: "span" }, labeled("Ref", "ref"), { control: "speed" }] : []),
183
+ labeled("Contrast", "floor"),
184
+ labeled("Avg", "average"),
185
+ labeled("FPS", "fps"),
186
+ labeled("Rows", "rows"),
187
+ { control: "step" },
188
+ { control: "display" },
189
+ ],
190
+ spacing: 8,
191
+ runSpacing: 4,
192
+ padding: 4,
193
+ },
194
+ { control: "spectrum", flex: 1 },
195
+ { scene: true, height: 24 },
196
+ ],
197
+ crossAxisAlignment: "stretch",
198
+ },
199
+ }
200
+ }
201
+
202
+ export function createPanafall(): PanelHook {
203
+ const displays = new Map<string, Display>()
204
+ const steps = new Map<string, string>()
205
+ return {
206
+ async getPanels() {
207
+ return [{ key: "panafall", title: "Panadapter", icon: "waveform", multiple: true, on: ["tick:2"] }]
208
+ },
209
+ async render(args): Promise<PanelContent> {
210
+ const id = args.instanceId ?? ""
211
+ return { kind: "scene", scene: scene(await host.readRadio(), displays.get(id) ?? "both", steps.get(id) ?? "100") }
212
+ },
213
+ async onEvent(args) {
214
+ const { action, value, text } = args.event
215
+ if (action === "step") {
216
+ steps.set(args.instanceId ?? "", text ?? "100")
217
+ return { values: {} }
218
+ }
219
+ if (action === "display") {
220
+ displays.set(args.instanceId ?? "", (text as Display) ?? "both")
221
+ return { values: {} }
222
+ }
223
+ const radio = await host.readRadio()
224
+ if (!radio?.id) return { values: {} }
225
+ if ((action === "tune" || action === "goto") && typeof value === "number") {
226
+ // Already on the step: the host snapped it.
227
+ await host.tuneRadio({ id: radio.id, frequencyHz: Math.round(value) })
228
+ } else if (action === "span" && text) {
229
+ await host.setRadioSpectrum({ id: radio.id, spanHz: Number(text) })
230
+ } else if (action === "ref" && typeof value === "number") {
231
+ await host.setRadioSpectrum({ id: radio.id, refLevelDb: value })
232
+ } else if (action === "speed" && text) {
233
+ await host.setRadioSpectrum({ id: radio.id, speed: text as "slow" | "mid" | "fast" })
234
+ }
235
+ return { values: {} }
236
+ },
237
+ }
238
+ }