@ham2k/extension-sdk 0.12.6 → 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.6
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.6",
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
+ }