@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 +20 -1
- package/docs/hooks.md +28 -1
- package/package.json +1 -1
- package/samples/README.md +1 -0
- package/samples/k2hrc-panafall/README.md +16 -0
- package/samples/k2hrc-panafall/build.mjs +6 -0
- package/samples/k2hrc-panafall/manifest.json +24 -0
- package/samples/k2hrc-panafall/src/index.ts +12 -0
- package/samples/k2hrc-panafall/src/panafall.ts +238 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @ham2k/extension-sdk 0.12.
|
|
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
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,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
|
+
}
|