@ham2k/extension-sdk 0.12.6 → 0.12.8

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.8
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -12,7 +12,22 @@ export interface PanelScene {
12
12
  controls?: PanelSceneControl[];
13
13
  /** Where rect-less native controls and the artwork go. Omitted, the artwork fills the panel. */
14
14
  layout?: PanelSceneLayoutNode;
15
+ /**
16
+ * Have the host feed a radio's live readings into `values` — the `PanelSceneRadioValue` names,
17
+ * updated as the radio reports them, with no render. `node` is a radio's id; omitted, the
18
+ * logging radio. Needs `requiresRadioRead` and extension API 7; without either, every reading
19
+ * is missing. A missing reading binds as the bottom of a binding's range and shows as `—`.
20
+ */
21
+ radio?: {
22
+ node?: string;
23
+ };
15
24
  }
25
+ /** Host-fed values of a scene with `radio`: its meters, and `radio.transmitting` as 1 or 0. Reserved: a scene may not declare them. */
26
+ export type PanelSceneRadioValue = "radio.signalDbm" | "radio.powerOut" | "radio.swr" | "radio.alc" | "radio.supplyVolts" | "radio.transmitting"
27
+ /** The SWR averaged over the transmission under way, or else the last one. Extension API 7. */
28
+ | "radio.swrAverage"
29
+ /** 0 while transmitting, then counting up; a long time when there has been none. Extension API 7. */
30
+ | "radio.secondsSinceTransmit";
16
31
  /** A clamped, linear mapping. No expressions or extension code run per frame. */
17
32
  export interface SceneBinding {
18
33
  value: string;
@@ -54,6 +69,8 @@ export interface PanelSceneLayer {
54
69
  decimals?: number;
55
70
  size?: number;
56
71
  color?: string;
72
+ /** `#RRGGBB`: a soft halo of this colour around the letters, to keep them legible over artwork. Extension API 7. */
73
+ glow?: string;
57
74
  samples?: (number | string)[];
58
75
  /** Multiply numeric text values before formatting. Defaults to 1. */
59
76
  scale?: number;
@@ -71,8 +88,11 @@ export interface PanelSceneLayer {
71
88
  rotation?: SceneBinding;
72
89
  translateX?: SceneBinding;
73
90
  translateY?: SceneBinding;
91
+ /** Scale about `pivot` — a bar's fill with `pivot: [0, 0.5]`. Extension API 7. */
92
+ scaleX?: SceneBinding;
93
+ scaleY?: SceneBinding;
74
94
  opacity?: SceneBinding;
75
- /** Rotation origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
95
+ /** Rotation and scale origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
76
96
  pivot?: [
77
97
  number,
78
98
  number
@@ -157,6 +177,19 @@ export interface PanelSceneSpectrum {
157
177
  gridColor?: string;
158
178
  /** The frequency lines carried down through the waterfall. Omitted, none. */
159
179
  waterfallGridColor?: string;
180
+ /**
181
+ * Keeps the tuned frequency at least this share of the width (0–0.45) from either edge, by
182
+ * moving the radio's scope just far enough to put it at that margin. Only where the radio lets
183
+ * the app place its scope — a FlexRadio, whose panadapter in SmartSDR moves with it — and the
184
+ * panel declares `requiresRadioWrite`; elsewhere it changes nothing. Extension API 7.
185
+ */
186
+ follow?: number;
187
+ /**
188
+ * Hz shown across the control, from the middle of each sweep. A sweep wider than this is cropped
189
+ * to it, so a radio whose scope comes in a few widths can be shown at any narrower one — set the
190
+ * scope (`setRadioSpectrum`) to the narrowest that covers it. Extension API 7.
191
+ */
192
+ span?: PanelSceneSpectrumNumber;
160
193
  /** A click tunes to the nearest multiple of this many Hz. Omitted, wherever it lands. */
161
194
  tuneStep?: PanelSceneSpectrumNumber;
162
195
  /** Dashes the panadapter's grid lines. */
@@ -195,6 +228,8 @@ export interface PanelSceneSpectrumScale {
195
228
  labels?: boolean | {
196
229
  /** Hz between labels. Omitted, the gridlines' step. */
197
230
  interval?: PanelSceneSpectrumNumber;
231
+ /** Decimals of MHz every label shows, 0–6. Omitted, as many as tell one label from the next. Extension API 7. */
232
+ decimals?: number;
198
233
  fontFamily?: string;
199
234
  size?: PanelSceneSpectrumNumber;
200
235
  /** 100–900. */
@@ -287,7 +322,10 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
287
322
  id: string;
288
323
  label: string;
289
324
  kind: PanelSceneControlKind;
290
- /** A button can open a native dropdown. Picking an item sends its event as the action. */
325
+ /**
326
+ * A button can open a native dropdown. Picking an item sends its event as the action. On a knob
327
+ * (extension API 7) a click opens it, while a scroll or a drag still turns the knob.
328
+ */
291
329
  menu?: {
292
330
  label: string;
293
331
  event: string;
@@ -349,6 +387,18 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
349
387
  valueLabels?: string[];
350
388
  /** Knobs use relative horizontal/upward dragging. Value per scene unit; default 1. */
351
389
  sensitivity?: number;
390
+ /**
391
+ * A knob's second axis: with `valueY`, horizontal scrolling, dragging and the left/right keys set
392
+ * `value`, and vertical ones (and up/down) set `valueY` — sent as its own event, `eventY`, which a
393
+ * knob with an `event` must give, different from it. Its range and feel are `minY`, `maxY`, `stepY`
394
+ * and `sensitivityY`. Extension API 7.
395
+ */
396
+ valueY?: string;
397
+ eventY?: string;
398
+ minY?: number;
399
+ maxY?: number;
400
+ stepY?: number;
401
+ sensitivityY?: number;
352
402
  }
353
403
  export interface PanelSceneEvent {
354
404
  controlId: string;
@@ -1748,6 +1798,50 @@ export interface PanelRadioState {
1748
1798
  transmitting: boolean | null;
1749
1799
  meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
1750
1800
  canTune: boolean;
1801
+ /** The model's rated output ceiling, where the app knows it (extension API 7). */
1802
+ maxWatts?: number | null;
1803
+ /**
1804
+ * The radio's antenna tuner as it reports it, or null for a radio without one, or whose driver
1805
+ * cannot read it (extension API 7).
1806
+ */
1807
+ tuner?: "inline" | "bypassed" | "tuning" | "failed" | null;
1808
+ /**
1809
+ * The receive filter's edges in Hz from the tuned frequency, as the radio shows them — `100`…`2800`
1810
+ * in USB, `-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot read it
1811
+ * (extension API 7).
1812
+ */
1813
+ passband?: {
1814
+ lowHz: number;
1815
+ highHz: number;
1816
+ } | null;
1817
+ /**
1818
+ * The radio's own antenna ports, by the radio's names, and which it uses — or null where its driver
1819
+ * cannot read them. `separate`: it picks receive and transmit antennas independently, as a FlexRadio
1820
+ * does; otherwise one port serves both (extension API 7).
1821
+ */
1822
+ antennas?: {
1823
+ rxPorts: string[];
1824
+ txPorts: string[];
1825
+ rx: string | null;
1826
+ tx: string | null;
1827
+ separate: boolean;
1828
+ } | null;
1829
+ /** The modes `setRadio` accepts for this radio, in the radio's order (extension API 7). */
1830
+ modes?: string[];
1831
+ /**
1832
+ * What the app keys with, while this is the logging radio, connected, and the app's keying
1833
+ * is on; null otherwise (extension API 7).
1834
+ */
1835
+ keying?: PanelRadioKeying | null;
1836
+ /**
1837
+ * The last transmission the radio finished since the app started: its average SWR over the readings taken with
1838
+ * RF out (null when there were none), and when it ended in real milliseconds — compare it with
1839
+ * `clock.realNowMillis` (extension API 7).
1840
+ */
1841
+ lastTransmission?: {
1842
+ swrAverage: number | null;
1843
+ endedAtMillis: number;
1844
+ } | null;
1751
1845
  /**
1752
1846
  * The radio's scope, on hosts with the panadapter enabled: absent there otherwise, and null
1753
1847
  * for a radio without one. The sweeps are never sent — a `spectrum` scene control draws them.
@@ -1783,21 +1877,80 @@ export interface PanelRadioSpectrumSet {
1783
1877
  refLevelDb?: number;
1784
1878
  speed?: PanelRadioSpectrumSpeed;
1785
1879
  }
1786
- export interface PanelRadioTune {
1880
+ /** The logging radio's keying: the designated key, radio1's voice memories and its PTT. */
1881
+ export interface PanelRadioKeying {
1882
+ canKeyCw: boolean;
1883
+ cwSending: boolean;
1884
+ /** The key's own report, clamping included; null before it has said. */
1885
+ keyerSpeedWpm: number | null;
1886
+ keyerSpeedRange: {
1887
+ min: number;
1888
+ max: number;
1889
+ } | null;
1890
+ canPtt: boolean;
1891
+ /** Whether the app's PTT latch is holding the transmitter. */
1892
+ ptt: boolean;
1893
+ /** Empty when the radio plays none. `number` is what `playRadioVoice` takes. */
1894
+ voiceMemories: {
1895
+ number: number;
1896
+ label: string;
1897
+ }[];
1898
+ }
1899
+ /** A radio identity and selection, as every command carries. */
1900
+ export interface PanelRadioTarget {
1787
1901
  /** Reject if this identity no longer matches the selected radio. */
1788
1902
  id: string;
1789
1903
  /** Pin a local radio by ID; omitted means follow the designated radio. */
1790
1904
  selection?: string;
1905
+ }
1906
+ export interface PanelRadioSet extends PanelRadioTarget {
1791
1907
  frequencyHz?: number;
1792
- mode?: "LSB" | "USB" | "CW" | "AM" | "FM";
1908
+ /**
1909
+ * The frequency comes from an on-screen dial turning, not a typed or clicked target: on the
1910
+ * logging radio, the app's Smart VFO Snap settles it like the radio's own knob (extension API 7).
1911
+ */
1912
+ dial?: boolean;
1913
+ /** One of the radio's `modes`. */
1914
+ mode?: string;
1915
+ /** Up to `maxWatts` where the radio states one. On the logging radio this is the power the next QSO logs. */
1916
+ powerWatts?: number;
1917
+ /** The logging radio's keyer; refused as `unsupported` where `keying.canKeyCw` is false. */
1918
+ keyerSpeedWpm?: number;
1919
+ /**
1920
+ * Puts the antenna tuner in line or bypasses it; refused as `unsupported` where `tuner` is null.
1921
+ * On a FlexRadio, in line is a TUNE — a carrier on the air — since its tuner has no other way in.
1922
+ */
1923
+ tunerInline?: boolean;
1924
+ /**
1925
+ * The receive filter, as `passband` reports it; refused as `unsupported` where that is null. A
1926
+ * radio that sets only a width from a table takes the nearest, keeping its own shift — read the
1927
+ * result back from `passband`.
1928
+ */
1929
+ passband?: {
1930
+ lowHz: number;
1931
+ highHz: number;
1932
+ };
1933
+ /** Ports from `antennas`; refused as `unsupported` where that is null. Without `separate`, either names the one port. */
1934
+ antenna?: {
1935
+ rx?: string;
1936
+ tx?: string;
1937
+ };
1793
1938
  }
1794
- export interface PanelRadioTuneResult {
1939
+ /** @deprecated `setRadio`'s argument through extension API 6. */
1940
+ export type PanelRadioTune = PanelRadioSet;
1941
+ export interface PanelRadioResult {
1795
1942
  /** Accepted by the host; confirmation still comes from reported state. */
1796
1943
  accepted: boolean;
1797
- /** `stopped`: a scope change while no display has the scope running — nothing was sent. */
1798
- reason?: "changed" | "disconnected" | "transmitting" | "invalid" | "stopped";
1944
+ /**
1945
+ * `stopped`: a scope change while no display has the scope running — nothing was sent.
1946
+ * `unsupported`: this radio, or the app's keying, cannot do it — keying calls on a radio that
1947
+ * is not the logging radio, or with the app's keying off.
1948
+ */
1949
+ reason?: "changed" | "disconnected" | "transmitting" | "invalid" | "stopped" | "unsupported";
1799
1950
  state: PanelRadioState | null;
1800
1951
  }
1952
+ /** @deprecated `PanelRadioResult`'s name through extension API 6. */
1953
+ export type PanelRadioTuneResult = PanelRadioResult;
1801
1954
  export interface PanelRenderArgs {
1802
1955
  /** Present on environment-capable hosts. Changes automatically request a throttled render of a `scene` panel. */
1803
1956
  environment?: PanelEnvironment;
@@ -2232,14 +2385,29 @@ export declare const hooks: {
2232
2385
  export declare const host: {
2233
2386
  listRadios(): Promise<PanelRadioState[]>;
2234
2387
  readRadio(id?: string): Promise<PanelRadioState | null>;
2235
- setRadioConnection(args: {
2236
- id: string;
2237
- selection?: string;
2388
+ setRadioConnection(args: PanelRadioTarget & {
2238
2389
  connected: boolean;
2239
- }): Promise<PanelRadioTuneResult>;
2240
- tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
2390
+ }): Promise<PanelRadioResult>;
2391
+ /** Sets frequency, mode, power, keyer speed, tuner, passband and antennas. Needs `requiresRadioWrite` and extension API 7. */
2392
+ setRadio(args: PanelRadioSet): Promise<PanelRadioResult>;
2393
+ /** @deprecated `setRadio`'s name through extension API 6, still answered by newer hosts. */
2394
+ tuneRadio(args: PanelRadioSet): Promise<PanelRadioResult>;
2241
2395
  /** Changes the radio's scope. Needs `requiresRadioWrite` and extension API 6; the result's state reports what the radio took. */
2242
- setRadioSpectrum(args: PanelRadioSpectrumSet): Promise<PanelRadioTuneResult>;
2396
+ setRadioSpectrum(args: PanelRadioSpectrumSet): Promise<PanelRadioResult>;
2397
+ /** Keys `text` on the logging radio's key, as the keyer area does. Needs `requiresRadioWrite` and extension API 7. */
2398
+ sendRadioCw(args: PanelRadioTarget & {
2399
+ text: string;
2400
+ }): Promise<PanelRadioResult>;
2401
+ /** Plays one of `keying.voiceMemories` once — never repeating. Needs `requiresRadioWrite` and extension API 7. */
2402
+ playRadioVoice(args: PanelRadioTarget & {
2403
+ memory: number;
2404
+ }): Promise<PanelRadioResult>;
2405
+ /** Keys or unkeys the logging radio's PTT through the app's own latch. Needs `requiresRadioWrite` and extension API 7. */
2406
+ setRadioPtt(args: PanelRadioTarget & {
2407
+ on: boolean;
2408
+ }): Promise<PanelRadioResult>;
2409
+ /** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
2410
+ stopRadioTransmit(args: PanelRadioTarget): Promise<PanelRadioResult>;
2243
2411
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
2244
2412
  webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
2245
2413
  kvGet(key: string): Promise<JSONValue | null>;
package/dist/index.js CHANGED
@@ -169,6 +169,11 @@ const host = {
169
169
  async setRadioConnection(args) {
170
170
  return await hostCallFn()("setRadioConnection", { ...args });
171
171
  },
172
+ /** Sets frequency, mode, power, keyer speed, tuner, passband and antennas. Needs `requiresRadioWrite` and extension API 7. */
173
+ async setRadio(args) {
174
+ return await hostCallFn()("setRadio", { ...args });
175
+ },
176
+ /** @deprecated `setRadio`'s name through extension API 6, still answered by newer hosts. */
172
177
  async tuneRadio(args) {
173
178
  return await hostCallFn()("tuneRadio", { ...args });
174
179
  },
@@ -176,6 +181,22 @@ const host = {
176
181
  async setRadioSpectrum(args) {
177
182
  return await hostCallFn()("setRadioSpectrum", { ...args });
178
183
  },
184
+ /** Keys `text` on the logging radio's key, as the keyer area does. Needs `requiresRadioWrite` and extension API 7. */
185
+ async sendRadioCw(args) {
186
+ return await hostCallFn()("sendRadioCw", { ...args });
187
+ },
188
+ /** Plays one of `keying.voiceMemories` once — never repeating. Needs `requiresRadioWrite` and extension API 7. */
189
+ async playRadioVoice(args) {
190
+ return await hostCallFn()("playRadioVoice", { ...args });
191
+ },
192
+ /** Keys or unkeys the logging radio's PTT through the app's own latch. Needs `requiresRadioWrite` and extension API 7. */
193
+ async setRadioPtt(args) {
194
+ return await hostCallFn()("setRadioPtt", { ...args });
195
+ },
196
+ /** Stops all CW, voice and PTT the app is keying, as Escape does. Needs `requiresRadioWrite` and extension API 7. */
197
+ async stopRadioTransmit(args) {
198
+ return await hostCallFn()("stopRadioTransmit", { ...args });
199
+ },
179
200
  async fetch(url, options) {
180
201
  return await hostCallFn()("fetch", { url, ...options });
181
202
  },
@@ -53,7 +53,12 @@ and `onShow`/`onHide`, and from 3 the build rewrites a bare `setTimeout` into
53
53
  the SDK's (README's "Timers"); 4 lets `host.fetch` reach a plain-`http://` host
54
54
  the app fronts with its https proxy (README's `host.fetch`); 5 adds panel
55
55
  scenes' `scene` kind, native controls, `strings`, `layout`, `after` and control
56
- `opacity` (hooks.md's `panel`).
56
+ `opacity` (hooks.md's `panel`); 6 adds the scene's `spectrum` control and
57
+ `host.setRadioSpectrum`; 7 adds `host.setRadio`, the keying calls
58
+ (`sendRadioCw`, `playRadioVoice`, `setRadioPtt`, `stopRadioTransmit`), what
59
+ `readRadio` adds with them, and the scene's host-fed `radio` values, scale
60
+ bindings, text glow, two-axis and menu knobs and the spectrum's `follow`,
61
+ `span` and label `decimals`.
57
62
 
58
63
  Three fields are **refused** in a distributed bundle, and it is worth
59
64
  understanding why:
@@ -262,7 +267,7 @@ It reports every problem at once rather than one per run:
262
267
  h2kext-pack: ./build is not ready to package:
263
268
  manifest.json: missing required field 'name'
264
269
  manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
265
- manifest.json: missing 'api' — declare the extension API this was built against (currently 6)
270
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 7)
266
271
  ```
267
272
 
268
273
  `--force-name` waives the naming convention below. It exists for Ham2K's own
package/docs/hooks.md CHANGED
@@ -1551,7 +1551,11 @@ which the SDK keeps as a deprecated alias along with the `SvgScene*` type names.
1551
1551
  `strings`, `layout`, `after` and control `opacity`. A bundle using any of them
1552
1552
  declares `"api": 5` ([distribution.md](distribution.md)), and an app speaking
1553
1553
  less refuses it at install rather than showing the panel as an unknown kind.
1554
- A plain scene that must reach those apps keeps `svgScene` and its lower API. Scene-capable hosts pass
1554
+ A plain scene that must reach those apps keeps `svgScene` and its lower API.
1555
+ **Extension API 7** adds the scene's host-fed `radio` values (*Radio values*
1556
+ below), `scaleX`/`scaleY` bindings, text `glow`, a knob's `menu` and second
1557
+ axis, and the spectrum control's `follow`, `span` and label `decimals`; a
1558
+ bundle using any of them declares `"api": 7`. Scene-capable hosts pass
1555
1559
  `instanceId` on render/event calls. `onEvent` handles control events and returns
1556
1560
  value patches (`values`, and `strings` for string controls); local-only controls and animation frames make no bridge
1557
1561
  calls. Controls with `continuous: true` send `change` events during dragging and a
@@ -1561,7 +1565,10 @@ for local numeric formatting; bindings accept `scale`, `truncate` and `modulo`
1561
1565
  too, applied to the value before the ramp. KI2D’s Radio Panel uses these for its Modern
1562
1566
  frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
1563
1567
  may hold numbers or preformatted strings; control `valueLabels` supplies accessible
1564
- value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1568
+ value descriptions. A layer binds `rotation`, `translateX`/`translateY`,
1569
+ `scaleX`/`scaleY` and `opacity`; rotation and scale turn about its `pivot`, so
1570
+ `scaleX` with `pivot: [0, 0.5]` grows a bar's fill from its left end.
1571
+ Transform/opacity bindings may also supply numeric `samples`,
1565
1572
  equally spaced over their `input` range, instead of the `output` ramp. The host
1566
1573
  interpolates and clamps these locally; chart markers and callouts can follow
1567
1574
  individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/panel-scenes.md)
@@ -1599,10 +1606,13 @@ Both `environment` and `clock` are optional in the SDK for older-host detection.
1599
1606
  Use scene **text layers** for typography; SVG `<text>` is not a portable text path
1600
1607
  through the renderer. Text supports either `literal` or bound `value`, plus
1601
1608
  `fontFamily`, `fontWeight`, `lineHeight`, and `letterSpacing`. Text participates
1602
- in native directionality, accessibility scaling and semantics.
1609
+ in native directionality, accessibility scaling and semantics. A text `glow`
1610
+ (`#RRGGBB`) draws a soft halo of that colour around the letters, to keep them
1611
+ legible over busy artwork.
1603
1612
  Button controls can supply `menu: [{label, event}]` to open a native dropdown.
1604
1613
  Selecting an item emits its `event` as the action with the original control ID;
1605
- dismissing the menu emits nothing.
1614
+ dismissing the menu emits nothing. A knob takes a `menu` too: a click opens it,
1615
+ while a scroll or a drag still turns the knob.
1606
1616
 
1607
1617
  **Native controls.** Besides the drawn `button`, `slider` and `knob` — invisible
1608
1618
  hit areas over the extension's own artwork — a scene can use Material widgets
@@ -1619,7 +1629,11 @@ on leaving it after typing; a render that lands while the operator is typing
1619
1629
  does not overwrite the typing. Every control's `label` is its accessible name;
1620
1630
  the app has no tooltips. A text field or dropdown in a panel keeps keyboard
1621
1631
  focus; clicking any other native control hands focus back to the callsign
1622
- field, as clicking a button anywhere else in the Operation view does.
1632
+ field, as clicking a button anywhere else in the Operation view does. A drawn
1633
+ `button` never takes focus from a click, so the operator's typing stays where
1634
+ it was. A drawn `slider` or `knob` clicked or dragged holds focus for three
1635
+ seconds after its last use — long enough for the arrow keys to adjust it — then
1636
+ hands it back to wherever it came from. Focus that arrives by Tab stays.
1623
1637
 
1624
1638
  **Layout.** A native control either has an `x`/`y`/`width`/`height` rect over the
1625
1639
  artwork, like the drawn kinds — it takes the rect's width and its own height, up
@@ -1664,6 +1678,7 @@ drawing part of it.
1664
1678
  | `layers` | Painted in order, each over the ones before: `svg` artwork or `text`. At most 128, and 1 MiB of artwork and text in all. |
1665
1679
  | `controls` | At most 64. Drawn or native; see below. |
1666
1680
  | `layout` | Optional. Where rect-less native controls and the artwork go; omitted, the artwork fills the pane. |
1681
+ | `radio` | Optional, `{node?}`: the host feeds that radio's live readings into the reserved `radio.*` values. `node` is an id from `listRadios`; omitted, the logging radio. See *Radio values*. |
1667
1682
 
1668
1683
  **Controls.** Every control has an `id` and a `label`, which is its accessible
1669
1684
  name. An `event` names the action sent when it is used; without one the control
@@ -1674,7 +1689,7 @@ the extension. `value` names what it shows and sets.
1674
1689
  |---|---|---|---|
1675
1690
  | `button` | — | `activate` | `menu` |
1676
1691
  | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous` |
1677
- | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous` |
1692
+ | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
1678
1693
  | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
1679
1694
  | `nativeSlider` | number | `commit` | `min`, `max`, `step`, `valueLabels`, `continuous` |
1680
1695
  | `nativeTextField` | string | `commit` on Enter, or on leaving it after typing | `placeholder`, `continuous` |
@@ -1702,6 +1717,15 @@ string is `""` or one of them; a `multi` choice holds its values joined with
1702
1717
  commas in option order, so no option may contain one. `menu` is
1703
1718
  `[{label, event}]`, up to 32: picking an item sends its `event` as the action.
1704
1719
 
1720
+ A knob with `valueY` sets two numbers from one patch of artwork — a passband's
1721
+ shift across and its width up, say. Horizontal dragging, horizontal scrolling
1722
+ and the left/right keys set `value`; vertical ones and the up/down keys set
1723
+ `valueY`, which must be in `values`, with its own range, step and feel from the
1724
+ `…Y` fields. The second axis sends its events with the action `eventY`, which a
1725
+ knob with an `event` must give, different from `event` — an event carries no
1726
+ other mark of which axis moved, and a scene without one is refused. The host queues the two axes separately, so neither
1727
+ replaces the other's pending value, and a drag commits only the axes it moved.
1728
+
1705
1729
  **Layout nodes.** Each is an object with exactly one of these keys:
1706
1730
 
1707
1731
  | Node | |
@@ -1796,6 +1820,11 @@ export const spotter: PanelHook = {
1796
1820
  }
1797
1821
  ```
1798
1822
 
1823
+ The callsign comes back upper-cased, and the field shows the extension's
1824
+ version once the typing is committed. A bundle carrying this declares
1825
+ `"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
1826
+ control faded in over the artwork, and a click-through layer painted over it.
1827
+
1799
1828
  #### Spectrum
1800
1829
 
1801
1830
  A `spectrum` control is a radio's panadapter and waterfall, drawn by the host
@@ -1820,7 +1849,20 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
1820
1849
  - `overlays`: up to 16 lines (`at`) and areas (`from`, `to`) in Hz from the
1821
1850
  tuned frequency, the sweep's middle, or absolute, each styled per part.
1822
1851
  - `scale`: the gap between the panadapter and the waterfall, its fill and
1823
- borders, and frequency `labels` and `ticks`.
1852
+ borders, and frequency `labels` and `ticks`. `labels.decimals` (0–6) fixes
1853
+ the decimals of MHz every label shows; omitted, as many as tell one label
1854
+ from the next.
1855
+ - `span`: Hz shown across the control, from the middle of each sweep — a
1856
+ number or a scene value. A wider sweep is cropped to it, so a radio whose
1857
+ scope comes in a few fixed widths can be shown at any narrower one; set the
1858
+ scope to the narrowest that covers it. Everything is drawn, labelled and
1859
+ clicked on the window shown.
1860
+ - `follow` (0–0.45): keeps the tuned frequency at least this share of the
1861
+ width from either edge, by moving the radio's scope just far enough to put
1862
+ it on that margin — the picture scrolls with the dial rather than jumping
1863
+ to centre it. Only where the radio lets the app place its scope (a
1864
+ FlexRadio, whose panadapter in SmartSDR moves with it) and the panel
1865
+ declares `requiresRadioWrite`; elsewhere it changes nothing.
1824
1866
  - `annotations`: up to 64 pieces of text at frequencies, with a pointer to
1825
1867
  where they sit. One with an `event` sends `activate` when clicked, its
1826
1868
  frequency as `value` and its `id` as `annotation`, and does not tune.
@@ -1828,10 +1870,35 @@ control and takes the fields of `PanelSceneSpectrum` in the SDK:
1828
1870
  - `tuneStep`: a click elsewhere sends `commit` with the frequency under it,
1829
1871
  rounded to this many Hz. The extension decides whether to tune.
1830
1872
 
1831
- The callsign comes back upper-cased, and the field shows the extension's
1832
- version once the typing is committed. A bundle carrying this declares
1833
- `"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
1834
- control faded in over the artwork, and a click-through layer painted over it.
1873
+ Each waterfall row stays at the frequencies it was taken on: when the radio
1874
+ retunes its scope the history slides sideways, and when the span changes it
1875
+ stretches, rather than old signals being redrawn where they never were.
1876
+
1877
+ #### Radio values
1878
+
1879
+ A scene with a document-level `radio` block (extension API 7) is fed that
1880
+ radio's live readings by the host, into reserved values a binding, a text
1881
+ layer or a control's `opacity` can read like any other:
1882
+
1883
+ | Value | |
1884
+ |---|---|
1885
+ | `radio.signalDbm` | Receive signal, dBm. |
1886
+ | `radio.powerOut` | RF out, watts. |
1887
+ | `radio.swr`, `radio.alc`, `radio.supplyVolts` | The transmit meters. |
1888
+ | `radio.transmitting` | 1 or 0. |
1889
+ | `radio.swrAverage` | The SWR averaged over the transmission under way, or else over the last one — only readings taken with RF out count. |
1890
+ | `radio.secondsSinceTransmit` | 0 while transmitting, then counting up; a very large number when there has been no transmission. |
1891
+
1892
+ They change as the radio reports them — several times a second — with no
1893
+ render and no bridge call, so a meter needle moves at the radio's rate while
1894
+ the extension renders once a second or not at all. A reading the radio has
1895
+ not given, or no longer fresh, is missing: a binding takes it as the bottom
1896
+ of its range and a text layer shows `—`. `radio.secondsSinceTransmit` is
1897
+ re-read on its own for fifteen seconds after a transmission, so a hold or a
1898
+ fade bound to it ends on time. The names are reserved — a scene may not
1899
+ declare them in `values` or `strings` — and readable only with the block;
1900
+ nothing may set them. The feed needs `requiresRadioRead`: without it every
1901
+ reading is missing.
1835
1902
 
1836
1903
  The `ki2d-weather-panel`, `ki2d-solar-panel` and `ki2d-radio-panel` reference
1837
1904
  panels live in
@@ -1843,8 +1910,10 @@ repository's dashboard README for building them.
1843
1910
  The radio host calls are manifest capabilities, refused unless declared — the
1844
1911
  same shape as `requiresLocation`, and shown to the operator at install:
1845
1912
  `"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
1846
- `"requiresRadioWrite": true` allows `host.tuneRadio()`,
1847
- `host.setRadioConnection()` and `host.setRadioSpectrum()`, and implies read, since a command answers with the
1913
+ `"requiresRadioWrite": true` allows `host.setRadio()`,
1914
+ `host.setRadioConnection()` and `host.setRadioSpectrum()` — and transmitting
1915
+ with them: `host.sendRadioCw()`, `host.playRadioVoice()`, `host.setRadioPtt()`
1916
+ and `host.stopRadioTransmit()` — and implies read, since a command answers with the
1848
1917
  radio's state. An undeclared call rejects rather than answering null.
1849
1918
  A capability belongs to the bundle that declared it, and to the sub-extensions
1850
1919
  that bundle defines under its own key. A separately installed bundle never
@@ -1859,19 +1928,93 @@ the hook); the control shows its pending value until the outcome is known.
1859
1928
  local transceivers. `host.readRadio(id?)` returns
1860
1929
  a **local** radio's reported frequency (Hz), mode, connection state,
1861
1930
  power, TX state and fresh telemetry, or null on hosts without this API.
1862
- `meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
1931
+ `meters` holds `signalDbm`, `powerOut`, `swr`, `alc` and `supplyVolts`, each
1932
+ only while its driver has a fresh reading. `meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
1863
1933
  times a second; it expires after two seconds without a reading. The Flex driver receives the selected slice’s LEVEL
1864
- meter through its native UDP channel. The reference panel draws a segmented
1934
+ meter, and the transmitter's forward power and SWR, through its native UDP channel. The reference panel draws a segmented
1865
1935
  receive meter and leaves it unlit when unavailable, stale, or transmitting. It does
1866
- not substitute the app's manually entered VFO for a radio reading.
1867
- `host.tuneRadio({id, frequencyHz?, mode?})` targets that same radio through the
1868
- existing VFO/CAT service. Supported mode requests are LSB, USB, CW, AM and FM.
1869
- The host rejects a changed designation, disconnected/stale radio, transmission
1870
- in progress or malformed command, and spaces accepted tuning requests by at
1871
- least 250 ms. Omitting a selection follows the designated logging radio; an explicit `selection`
1872
- pins tuning to that local radio without changing the logging designation.
1936
+ not substitute the app's manually entered VFO for a radio reading. A scene
1937
+ that only draws meters reads them as *Radio values* instead, at the radio's
1938
+ rate. Extension API 7 adds to the answer:
1939
+
1940
+ - `maxWatts`: the model's rated output, where the app knows it; `modes`: the
1941
+ modes `setRadio` accepts for this radio, in the radio's order.
1942
+ - `tuner`: `inline`, `bypassed`, `tuning` or `failed`; null for a radio
1943
+ without one, or whose driver cannot read it.
1944
+ - `passband`: `{lowHz, highHz}`, the receive filter's edges in Hz from the
1945
+ tuned frequency as the radio shows them — `100`…`2800` in USB,
1946
+ `-2800`…`-100` in LSB, around zero in CW — or null where its driver cannot
1947
+ read it.
1948
+ - `antennas`: `{rxPorts, txPorts, rx, tx, separate}`, the radio's own antenna
1949
+ ports by the radio's names and which it uses, or null where its driver
1950
+ cannot read them. `separate`: it picks receive and transmit independently,
1951
+ as a FlexRadio does; otherwise one port serves both.
1952
+ - `keying`: what the app keys with — `canKeyCw`, `cwSending`,
1953
+ `keyerSpeedWpm` (the key's own report, null before it has given one),
1954
+ `keyerSpeedRange`, `canPtt`, `ptt` (whether the app's PTT latch holds the
1955
+ transmitter) and `voiceMemories` (`[{number, label}]`, empty for a radio
1956
+ that plays none) — while this is the logging radio, connected, on a host
1957
+ with keying enabled; null otherwise.
1958
+ - `lastTransmission`: `{swrAverage, endedAtMillis}` for the last
1959
+ transmission the radio finished since the app started — its SWR averaged over the readings
1960
+ taken with RF out (null when there were none), and when it ended, in real
1961
+ milliseconds to compare with `clock.realNowMillis` — or null.
1962
+
1963
+ `host.setRadio({id, selection?, ...settings})` sets any of these on that same
1964
+ radio, through the existing VFO/CAT service. It and the keying calls below are
1965
+ extension API 7: a bundle using them declares `"api": 7`.
1966
+
1967
+ - `frequencyHz`; `dial: true` says it comes from an on-screen dial turning,
1968
+ not a typed or clicked target, and on the logging radio Smart VFO Snap
1969
+ settles it like the radio's own knob.
1970
+ - `mode`: one of `modes`.
1971
+ - `powerWatts`: up to `maxWatts` where that is stated. On the logging radio
1972
+ it goes through the app's VFO, so it is the power the next QSO logs.
1973
+ - `keyerSpeedWpm`: the logging radio's keyer, which clamps to its own range —
1974
+ `keying.keyerSpeedWpm` says what it took.
1975
+ - `tunerInline`: puts the antenna tuner in line or bypasses it. On a
1976
+ FlexRadio, whose tuner has no other way in, in line is a TUNE — a carrier
1977
+ on the air; other radios switch it without transmitting.
1978
+ - `passband`: `{lowHz, highHz}`, as `passband` reports it. A radio that sets
1979
+ only a width from a table takes the nearest, keeping its own shift — read
1980
+ the result back from `passband`. It applies to the mode the radio is in: a
1981
+ call that also changes `mode` is `invalid`.
1982
+ - `antenna`: `{rx?, tx?}`, ports from `antennas`; without `separate`, either
1983
+ names the one port, and two different ones are `invalid`. An `rx` naming
1984
+ the `tx` asked for in the same call is taken even where `rxPorts` does not
1985
+ list it yet.
1986
+
1987
+ The host rejects a changed designation (`changed`), a disconnected or stale
1988
+ radio (`disconnected`), a transmission in progress (`transmitting`) and a
1989
+ malformed command or a value out of range (`invalid`); a setting this radio
1990
+ cannot take — a tuner, passband or antenna its readout has as null, a keyer
1991
+ speed without `keying.canKeyCw` — answers `unsupported`. Accepted requests are
1992
+ spaced by at least 250 ms, since a dragged control would otherwise flood the
1993
+ CAT link. Omitting a selection follows the designated logging radio; an explicit `selection`
1994
+ pins the command to that local radio without changing the logging designation.
1873
1995
  An accepted result means requested, not confirmed; only subsequent
1874
1996
  reported state confirms it. Drivers retain their own protocol/echo handling.
1997
+
1998
+ The keying calls transmit, and act only on the logging radio, on a host with
1999
+ keying enabled — a panel pinned to another radio is answered `unsupported`
2000
+ rather than keying the logging radio behind the operator's back:
2001
+
2002
+ - `host.sendRadioCw({id, selection?, text})` keys `text` (up to 1000
2003
+ characters) on the key in use, as the keyer area's messages do.
2004
+ - `host.playRadioVoice({id, selection?, memory})` plays one of
2005
+ `keying.voiceMemories` once. It never arms a repeat: a CQ repeating on a
2006
+ panel's say-so is one the operator did not start where they would stop it.
2007
+ - `host.setRadioPtt({id, selection?, on})` keys or unkeys PTT through the
2008
+ app's own latch, so Escape releases it as it does the operator's own.
2009
+ `on: false` releases whatever key the app holds and is never refused; a
2010
+ radio that does not take the key answers `on: true` with `disconnected`.
2011
+ - `host.stopRadioTransmit({id, selection?})` stops every CW message, voice
2012
+ memory and PTT the app is keying, as Escape does, whatever radio the
2013
+ identity names: a stop is never the call that is refused.
2014
+
2015
+ A radio that cannot do what is asked (no CW key, no voice memories, no PTT)
2016
+ answers `unsupported`; one that is not connected, `disconnected`. These are
2017
+ not paced, and not refused for a transmission in progress.
1875
2018
  `host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
1876
2019
  connection using the same selection and identity checks. Acceptance starts the
1877
2020
  operation; follow reported connection state for completion. It does not switch
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.8",
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 7) |
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": 7,
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.setRadio({ 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
+ }