@ham2k/extension-sdk 0.12.13 → 0.12.15

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.13
1
+ // @ham2k/extension-sdk 0.12.15
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -21,6 +21,20 @@ export interface PanelScene {
21
21
  radio?: {
22
22
  node?: string;
23
23
  };
24
+ /**
25
+ * A scene longer than its pane along `axis` (default `y`), scrolled by the app itself — wheel,
26
+ * trackpad and drag, a drag begun on a button included — with no call to the extension. Scaled
27
+ * to fill the pane across, it runs on past it along the axis. The position, in scene units from
28
+ * the start, is written to the scene value `value` as it scrolls; a render that changes that
29
+ * value moves the view there, and one that leaves it alone leaves the operator's scroll alone.
30
+ * `event`, when given, is sent as a `commit` with the position as `value` once scrolling comes
31
+ * to rest. Layers and controls marked `fixed` stay put over the pane. No `layout`. Extension API 12.
32
+ */
33
+ scroll?: {
34
+ axis?: "x" | "y";
35
+ value: string;
36
+ event?: string;
37
+ };
24
38
  }
25
39
  /** 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
40
  export type PanelSceneRadioValue = "radio.signalDbm" | "radio.powerOut" | "radio.swr" | "radio.alc" | "radio.supplyVolts" | "radio.transmitting"
@@ -52,13 +66,28 @@ export interface PanelSceneLayer {
52
66
  y: number;
53
67
  width: number;
54
68
  height: number;
55
- /** Supply exactly one of svg or text. SVG must be self-contained. */
69
+ /** Supply exactly one of svg, text or icon. SVG must be self-contained. */
56
70
  svg?: string;
71
+ /**
72
+ * A named icon — MDI, or FontAwesome with `fa-` — drawn by the app to fit the layer, in `color`
73
+ * (`#RRGGBB`) or the theme's. Extension API 12.
74
+ */
75
+ icon?: {
76
+ name: string;
77
+ color?: string;
78
+ };
57
79
  /**
58
80
  * Take the clicks that land on this layer. Off by default: layers are click-through, so
59
81
  * artwork painted over a control does not stop it working. On, it covers the controls under it.
60
82
  */
61
83
  blocksPointer?: boolean;
84
+ /** In a scene with `scroll`: drawn over the pane where it is, in pane coordinates, not scrolled. Extension API 12. */
85
+ fixed?: boolean;
86
+ /**
87
+ * While a `zoom` strip is dragged, scaled along the scroll about the pane's middle, rather than
88
+ * moved there at its own size: for a background, or a line that runs the whole way. Extension API 12.
89
+ */
90
+ stretch?: boolean;
62
91
  /** Exactly one of literal/value. A value naming a string shows it as-is. Use text layers, not SVG <text>, for font support.
63
92
  * Size is unscaled; the host applies OS text scaling once. */
64
93
  text?: {
@@ -121,10 +150,16 @@ export interface PanelSceneLayer {
121
150
  * reach the extension. Needs `requiresRadioRead`; a host without the feature, or a radio
122
151
  * without a scope, shows why instead. A click sends `commit` with the frequency as `value`.
123
152
  * Takes no `value`; see `PanelSceneSpectrum` for the rest. Extension API 6: declare `"api": 6`.
153
+ * - `zoom`: a strip of a scene with `scroll`, drawn by nothing, where a drag along the scroll zooms
154
+ * about the pane's middle — away from it in, toward it out, by the ratio of the distances. While
155
+ * the drag lasts the app moves each layer and control to where the zoom puts its middle, at its
156
+ * own size (a `stretch` layer scales instead), and on release sends `commit` with the factor as
157
+ * `value`; `min` and `max` are the factors allowed. A tap on it still reaches what lies there,
158
+ * and a drag elsewhere still scrolls. Needs `event`. Extension API 12.
124
159
  *
125
160
  * `label` is every control's accessible name. There are no tooltips.
126
161
  */
127
- export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText" | "spectrum";
162
+ export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText" | "spectrum" | "zoom";
128
163
  /** A literal, or the name of a scene value — which a slider with no `event` retunes with no bridge call. */
129
164
  export type PanelSceneSpectrumNumber = number | string;
130
165
  /**
@@ -361,6 +396,18 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
361
396
  multi?: boolean;
362
397
  /** Shown but inert: no clicks, drags, keys or screen-reader adjustments act on it. */
363
398
  disabled?: boolean;
399
+ /** In a scene with `scroll`: placed over the pane, in pane coordinates, not scrolled. Needs a rect. Extension API 12. */
400
+ fixed?: boolean;
401
+ /**
402
+ * A drawn control only (`button`, `slider`, `knob`): while the pointer is over it, the scene value
403
+ * `value` reads `to`, set by the host with no call to the extension; leaving puts back the scene's
404
+ * own value. Artwork bound to it lights up under the pointer — a spot's line with its call.
405
+ * Survives renders while the pointer stays. Extension API 12.
406
+ */
407
+ hoverSet?: {
408
+ value: string;
409
+ to: number;
410
+ };
364
411
  /**
365
412
  * 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
366
413
  * keyboard focus or screen-reader node. Anything between is only drawn fainter.
@@ -387,6 +434,17 @@ export interface PanelSceneControl extends PanelSceneSpectrum {
387
434
  valueLabels?: string[];
388
435
  /** Knobs use relative horizontal/upward dragging. Value per scene unit; default 1. */
389
436
  sensitivity?: number;
437
+ /**
438
+ * A knob only: a drag moves the value the other way — content held under the finger, scrolled
439
+ * by dragging, rather than a dial turned. Scrolling is unchanged. Extension API 12.
440
+ */
441
+ invertDrag?: boolean;
442
+ /**
443
+ * A slider or knob only: it also takes the scrolls that land on drawn buttons over it — labels
444
+ * drawn over a knob that scrolls the artwork. A native control, a `blocksPointer` layer or another
445
+ * slider or knob in between keeps the scroll. Extension API 12.
446
+ */
447
+ scrollThrough?: boolean;
390
448
  /**
391
449
  * A knob's second axis: with `valueY`, horizontal scrolling, dragging and the left/right keys set
392
450
  * `value`, and vertical ones (and up/down) set `valueY` — sent as its own event, `eventY`, which a
@@ -687,8 +745,22 @@ export interface GetSpotsOptions {
687
745
  annotate?: boolean;
688
746
  operationUuid?: string;
689
747
  }
748
+ export interface SuggestedQso {
749
+ their: {
750
+ call: string;
751
+ };
752
+ freq?: number;
753
+ band?: string;
754
+ mode?: string;
755
+ refs?: SpotRef[];
756
+ }
690
757
  export interface BoardSpot extends Spot {
691
758
  hookKey?: string;
759
+ display?: {
760
+ icon: string;
761
+ iconColor?: string;
762
+ emoji?: string;
763
+ };
692
764
  annotations?: SpotAnnotations;
693
765
  }
694
766
  export interface SpotAnnotations {
@@ -1537,6 +1609,7 @@ export interface ExtensionManifest {
1537
1609
  requiresLocation?: boolean;
1538
1610
  requiresRadioRead?: boolean;
1539
1611
  requiresRadioWrite?: boolean;
1612
+ requiresRadioRaw?: boolean;
1540
1613
  requiresClusterRead?: boolean;
1541
1614
  requiresClusterSpot?: boolean;
1542
1615
  requiresClusterCommand?: boolean;
@@ -1993,7 +2066,7 @@ export interface PanelRadioState {
1993
2066
  endedAtMillis: number;
1994
2067
  } | null;
1995
2068
  /**
1996
- * The radio's scope, on hosts with the panadapter enabled: absent there otherwise, and null
2069
+ * The radio's scope: absent on an app without a panadapter, and null
1997
2070
  * for a radio without one. The sweeps are never sent — a `spectrum` scene control draws them.
1998
2071
  */
1999
2072
  spectrum?: PanelRadioSpectrum | null;
@@ -2095,15 +2168,44 @@ export interface PanelRadioSet extends PanelRadioTarget {
2095
2168
  }
2096
2169
  /** @deprecated `setRadio`'s argument through extension API 6. */
2097
2170
  export type PanelRadioTune = PanelRadioSet;
2171
+ /**
2172
+ * Commands in the radio's own protocol — exactly one of `text` or `bytes`, up to 4096 bytes
2173
+ * (extension API 11). Where the app's driver keeps one question on the wire (CI-V, Yaesu binary,
2174
+ * HRD, Commander, flrig) each command waits its turn in that queue, and on a FlexRadio is numbered
2175
+ * like the driver's own; the reply is then the answers to these commands alone. Elsewhere the bytes
2176
+ * are written as given. See hooks.md for what each protocol takes.
2177
+ */
2178
+ export interface PanelRadioRaw extends PanelRadioTarget {
2179
+ /** Sent as UTF-8: Kenwood, Yaesu and Elecraft ASCII, rigctld, N3FJP, SmartSDR, HRD, Commander, flrig. */
2180
+ text?: string;
2181
+ /** Each 0…255: Icom CI-V, Yaesu's binary protocol. */
2182
+ bytes?: number[];
2183
+ /**
2184
+ * How long to wait for the reply, up to 2000 ms; omitted or 0, the call answers once the commands
2185
+ * are handed to the driver. Where they are written as given, the reply is everything the radio
2186
+ * sent in that time — the answers to the app's own polls included.
2187
+ */
2188
+ replyMs?: number;
2189
+ /** Ends the reply early, just after the first occurrence of this text or these bytes. Needs `replyMs`. */
2190
+ until?: string | number[];
2191
+ }
2192
+ export interface PanelRadioRawResult extends PanelRadioResult {
2193
+ /** What came back within `replyMs`, possibly nothing, at most 64 KiB; absent without `replyMs`. `text` is it as UTF-8. */
2194
+ reply?: {
2195
+ text: string;
2196
+ bytes: number[];
2197
+ };
2198
+ }
2098
2199
  export interface PanelRadioResult {
2099
2200
  /** Accepted by the host; confirmation still comes from reported state. */
2100
2201
  accepted: boolean;
2101
2202
  /**
2102
2203
  * `stopped`: a scope change while no display has the scope running — nothing was sent.
2204
+ * `busy`: eight raw commands to this radio are already waiting (extension API 11).
2103
2205
  * `unsupported`: this radio, or the app's keying, cannot do it — keying calls on a radio that
2104
2206
  * is not the logging radio, or with the app's keying off.
2105
2207
  */
2106
- reason?: "changed" | "disconnected" | "transmitting" | "invalid" | "stopped" | "unsupported";
2208
+ reason?: "changed" | "disconnected" | "transmitting" | "invalid" | "stopped" | "unsupported" | "busy";
2107
2209
  state: PanelRadioState | null;
2108
2210
  }
2109
2211
  /** @deprecated `PanelRadioResult`'s name through extension API 6. */
@@ -2565,6 +2667,11 @@ export declare const host: {
2565
2667
  }): Promise<PanelRadioResult>;
2566
2668
  /** Stops all CW, voice and PTT the app is keying, and a tune it started (extension API 9), as Escape does. Needs `requiresRadioWrite` and extension API 7. */
2567
2669
  stopRadioTransmit(args: PanelRadioTarget): Promise<PanelRadioResult>;
2670
+ /**
2671
+ * Writes any command in the radio's own protocol — one that transmits included — and collects the
2672
+ * reply for `replyMs`. Needs `requiresRadioRaw` and extension API 11.
2673
+ */
2674
+ sendRadioRaw(args: PanelRadioRaw): Promise<PanelRadioRawResult>;
2568
2675
  /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
2569
2676
  clusterStatus(): Promise<ClusterStatus>;
2570
2677
  /** The node's recent lines after `after`, oldest first, at most `limit` of the newest. Needs `requiresClusterRead` and extension API 10. */
@@ -2579,6 +2686,20 @@ export declare const host: {
2579
2686
  clusterPostSpot(args: ClusterPostSpotRequest): Promise<ClusterReply>;
2580
2687
  /** Sends any one command line, in the operator's name. Needs `requiresClusterCommand` and extension API 10. */
2581
2688
  clusterSend(command: string): Promise<ClusterReply>;
2689
+ /**
2690
+ * Puts a station into the logging fields of the operation in front, as tapping its spot in the
2691
+ * Spots Panel does — call, frequency, band, mode and references; nothing else of `qso` is used.
2692
+ * With `operationUuid` (a panel's `operation.uuid`), only that operation's: `accepted` is false
2693
+ * when it is not the one in front, or none is open. It tunes the radio as a tap does, so it needs
2694
+ * `requiresRadioWrite`, and it is refused outside the extension's own panel `onEvent`. Extension
2695
+ * API 12.
2696
+ */
2697
+ suggestQso(args: {
2698
+ qso: SuggestedQso;
2699
+ operationUuid?: string;
2700
+ }): Promise<{
2701
+ accepted: boolean;
2702
+ }>;
2582
2703
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
2583
2704
  webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
2584
2705
  kvGet(key: string): Promise<JSONValue | null>;
package/dist/index.js CHANGED
@@ -197,6 +197,13 @@ const host = {
197
197
  async stopRadioTransmit(args) {
198
198
  return await hostCallFn()("stopRadioTransmit", { ...args });
199
199
  },
200
+ /**
201
+ * Writes any command in the radio's own protocol — one that transmits included — and collects the
202
+ * reply for `replyMs`. Needs `requiresRadioRaw` and extension API 11.
203
+ */
204
+ async sendRadioRaw(args) {
205
+ return await hostCallFn()("sendRadioRaw", { ...args });
206
+ },
200
207
  /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
201
208
  async clusterStatus() {
202
209
  return await hostCallFn()("clusterStatus", {});
@@ -220,6 +227,17 @@ const host = {
220
227
  async clusterSend(command) {
221
228
  return await hostCallFn()("clusterSend", { command });
222
229
  },
230
+ /**
231
+ * Puts a station into the logging fields of the operation in front, as tapping its spot in the
232
+ * Spots Panel does — call, frequency, band, mode and references; nothing else of `qso` is used.
233
+ * With `operationUuid` (a panel's `operation.uuid`), only that operation's: `accepted` is false
234
+ * when it is not the one in front, or none is open. It tunes the radio as a tap does, so it needs
235
+ * `requiresRadioWrite`, and it is refused outside the extension's own panel `onEvent`. Extension
236
+ * API 12.
237
+ */
238
+ async suggestQso(args) {
239
+ return await hostCallFn()("suggestQso", { ...args });
240
+ },
223
241
  async fetch(url, options) {
224
242
  return await hostCallFn()("fetch", { url, ...options });
225
243
  },
@@ -540,6 +540,13 @@ catalog's own bundle links (`…/api/v1/extensions/<key>/versions/<version>/bund
540
540
  take this verified path when they arrive as a deep link; every other host
541
541
  takes the plain one above.
542
542
 
543
+ An extension's screenshots — published beside its bundles, never inside them
544
+ (the tools README says how) — show as a strip of thumbnails on its row in the
545
+ operation sheet's activity search, on the consent screen of a catalog install,
546
+ and in an installed extension's details; a tap opens them full size. The app
547
+ loads them only from the catalog's own host: a listing naming any other is
548
+ shown without them.
549
+
543
550
  ### Release tracks
544
551
 
545
552
  A catalog channel — `stable`, `unstable` or `bleeding` — says how finished a
@@ -573,7 +580,7 @@ counts as the catalog's own here. The row and the details show the track, and
573
580
  the details' **Follow stable releases only** puts it back on stable; an
574
581
  uninstall or a file install clears it with the rest of the record.
575
582
 
576
- At most once every six hours — on launch and on resume — the app asks the
583
+ At most once a day — on launch and on resume — the app asks the
577
584
  catalog whether anything installed has a newer release, and says so in the
578
585
  status bar and on the row; the notice comes down with the last update taken.
579
586
  It never installs unasked: the notice's **Update all** is the panel's own,
package/docs/hooks.md CHANGED
@@ -73,6 +73,17 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
73
73
  reads above it is cheap enough for the lookup path; it resolves `[]`
74
74
  whenever the cache is cold or the host keeps no spots. Used by
75
75
  `spot-history` to pull a hunted park or summit into the QSO being logged.
76
+ * `host.suggestQso({qso, operationUuid?})` (extension API 12) — puts a
77
+ station into the logging fields of the operation in front, as tapping its
78
+ spot in the Spots Panel does: `qso.their.call`, and `freq`, `band`, `mode`
79
+ and `refs` where given; nothing else of `qso` reaches the fields. With
80
+ `operationUuid` — a panel's own `operation.uuid` — only that operation's
81
+ fields, so a panel never fills a log the operator is not looking at.
82
+ Answers `{accepted}`, false when no such operation is open. It tunes the
83
+ radio as a tap does, so it needs `requiresRadioWrite`, and it is refused
84
+ except from inside the extension's own panel `onEvent`: it answers the
85
+ operator clicking something, never a timer or a render, which would
86
+ overwrite what they are typing.
76
87
  * `getSpots?(options?)` — the whole spots board the host already holds,
77
88
  for a panel showing spots its own way. Like `getSpotsForCall` it reads the
78
89
  cache and never fetches, and resolves `[]` while the cache is cold. By
@@ -92,7 +103,12 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
92
103
  ones the app already holds for the operation open on screen, so the call
93
104
  never waits on a scorer; another operation's spots, and that one's until
94
105
  its first verdicts land, come back unannotated rather than guessed.
95
- `annotate: false` returns the spots as their sources gave them.
106
+ From extension API 12 each also carries `display` — `icon` (an MDI or
107
+ `fa-` name), `iconColor` (`#RRGGBB`, where the activity declares one) and
108
+ `emoji` (the call's note emoji, where it has one): what the Spots Panel's row
109
+ shows beside the call, the activity's icon and tint for the spot's first
110
+ reference, else the source's own icon. `annotate: false` returns the spots
111
+ as their sources gave them.
96
112
  A panel's `'spots'` trigger fires whenever this answer may have moved: a
97
113
  refresh, new verdicts, the operator's spot filters or bands, or the radio's
98
114
  band or mode. A malformed or unknown option rejects the call. The CLI host
@@ -287,6 +303,14 @@ interface LookupServiceHook {
287
303
  unset or name itself; tagging one `'prefix'` gets its coordinate
288
304
  discarded.
289
305
 
306
+ - **A guess field of the wrong type is dropped.** The bridge enforces none of
307
+ `CallInfo`'s declared types, so the app normalizes the answer
308
+ (`normalizeLookupResult`, halo_core) before anything reads it: a field its
309
+ readers cannot use — a numeric `continent`, a boolean `emoji` — is dropped
310
+ and reported once by name, and the rest of the guess is still used. A
311
+ numeric string where a number is read (`cqZone: '05'`) is taken as that
312
+ number.
313
+
290
314
  Implemented by: `lookups` (key `core`). The merge algorithm itself
291
315
  (`mergeLookupIntoGuess`/`runLookupPipeline` in
292
316
  `extensions/core/lookups/src/mergeLookup.ts`) is a pure, host-independent
@@ -1876,9 +1900,10 @@ drawing part of it.
1876
1900
  | `width`, `height` | The artwork's viewBox, in scene units. Size it to `environment.width`/`height` and a scene unit is a logical pixel. |
1877
1901
  | `values` | `{name: number}`: what bindings, numeric text and numeric controls read. At most 256. |
1878
1902
  | `strings` | `{name: string}`: what text fields and choices hold. At most 256, each up to 4096 characters. A name is in `values` or `strings`, never both. |
1879
- | `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. |
1880
- | `controls` | At most 64. Drawn or native; see below. |
1903
+ | `layers` | Painted in order, each over the ones before: `svg` artwork, `text`, or an `icon`. At most 512 (128 before extension API 12), and 1 MiB of artwork and text in all. |
1904
+ | `controls` | At most 256 (64 before extension API 12). Drawn or native; see below. |
1881
1905
  | `layout` | Optional. Where rect-less native controls and the artwork go; omitted, the artwork fills the pane. |
1906
+ | `scroll` | Optional, `{axis?, value, event?}` (extension API 12): a scene longer than its pane along `axis` (`y` by default), scrolled natively by the app — wheel, trackpad and drag, a drag begun on a button included — with no call to the extension. It fills the pane across and runs on past it. The position, in scene units, is written to the scene value `value`; a render that changes that value moves the view there, one that leaves it alone leaves the operator's scroll alone. `event` is sent as a `commit`, the position as `value`, once a scroll comes to rest. Layers and controls with `fixed: true` stay put over the pane, in pane coordinates. A drag along the scroll on a slider or knob that scrolls with the content is the scroll's: make one that must be dragged `fixed`. Not with `layout`. |
1882
1907
  | `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*. |
1883
1908
 
1884
1909
  **Controls.** Every control has an `id` and a `label`, which is its accessible
@@ -1889,8 +1914,8 @@ the extension. `value` names what it shows and sets.
1889
1914
  | Kind | Binds to | Sends | Also takes |
1890
1915
  |---|---|---|---|
1891
1916
  | `button` | — | `activate` | `menu` |
1892
- | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous` |
1893
- | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
1917
+ | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous`, `scrollThrough` |
1918
+ | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `invertDrag`, `scrollThrough`, `valueLabels`, `continuous`, `menu`, a second axis (`valueY`, `eventY`, `minY`, `maxY`, `stepY`, `sensitivityY`) |
1894
1919
  | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
1895
1920
  | `nativeSlider` | number | `commit` | `min`, `max`, `step`, `valueLabels`, `continuous` |
1896
1921
  | `nativeTextField` | string | `commit` on Enter, or on leaving it after typing | `placeholder`, `continuous` |
@@ -1901,6 +1926,7 @@ the extension. `value` names what it shows and sets.
1901
1926
  | `nativeChip` | number, 1 or 0; or none | `commit`; `activate` without a value | `icon`; needs `event` without a value |
1902
1927
  | `nativeText` | number, string, or none | nothing | `style` (a typography role), `align` (`start`, `center`, `end`) |
1903
1928
  | `spectrum` | — | `commit` with the frequency clicked; `activate` from an annotation | see *Spectrum* below |
1929
+ | `zoom` | — | `commit` with the factor, on release | `min`, `max` (the factors allowed); only in a scene with `scroll`; extension API 12 |
1904
1930
 
1905
1931
  The three drawn kinds are invisible hit areas over artwork the extension paints;
1906
1932
  the native kinds are Material widgets the app paints and themes. Every control
@@ -1912,12 +1938,37 @@ may also take:
1912
1938
  - `after`: with a rect, the layer it paints directly after.
1913
1939
  - `opacity`: 0 to 1, fixed or bound like a layer's. At 0 the control is gone.
1914
1940
  - `disabled`: shown, but no click, drag, key or screen-reader adjustment acts on it.
1941
+ - `hoverSet` (drawn kinds only; extension API 12): `{value, to}`. While the
1942
+ pointer is over the control, the scene value `value` reads `to` — set by the
1943
+ host, with no call to the extension — and leaving puts back the scene's own
1944
+ value. A layer bound to it lights up under the pointer, and several controls
1945
+ naming one value with different `to`s light up one thing each. It survives
1946
+ renders while the pointer stays.
1915
1947
 
1916
1948
  `options` is `[{label, value}]`, up to 64, with each `value` unique. A choice's
1917
1949
  string is `""` or one of them; a `multi` choice holds its values joined with
1918
1950
  commas in option order, so no option may contain one. `menu` is
1919
1951
  `[{label, event}]`, up to 32: picking an item sends its `event` as the action.
1920
1952
 
1953
+ A `zoom` strip is drawn by nothing: it claims a drag that begins on it along a scrolling
1954
+ scene's axis. Away from the pane's middle zooms in and toward it zooms out, by the ratio of
1955
+ the two distances from the middle, clamped to `min`…`max`. While the drag lasts the app moves
1956
+ each layer and control to where the zoom puts its middle, keeping its size, so text and dots
1957
+ stay as drawn and only spread apart or close in; a layer with `stretch: true` — a background,
1958
+ a line the length of the scene — scales along the scroll instead. That preview holds until
1959
+ the render that answers the `commit` arrives, so a scene meant to zoom draws its pieces as
1960
+ layers of their own rather than as one picture. A tap on the strip still reaches the button there, and a drag that starts off it
1961
+ still scrolls.
1962
+
1963
+ A scroll steps a slider or knob by its `step`, down and left lowering it; a one-axis
1964
+ control takes a sideways scroll by its own direction. A slider or knob with
1965
+ `scrollThrough` (API 12) also takes the scrolls landing on drawn buttons over it, so labels
1966
+ drawn over a knob that scrolls the artwork do not stop the scroll; anything else in the
1967
+ way — a native control, a `blocksPointer` layer, another slider or knob — keeps the scroll
1968
+ from it.
1969
+ A knob with `invertDrag` (API 12) moves the other way to a drag, as content held under
1970
+ the finger does — what a knob that scrolls the artwork wants — while its scroll is unchanged.
1971
+
1921
1972
  A knob with `valueY` sets two numbers from one patch of artwork — a passband's
1922
1973
  shift across and its width up, say. Horizontal dragging, horizontal scrolling
1923
1974
  and the left/right keys set `value`; vertical ones and the up/down keys set
@@ -2029,9 +2080,8 @@ control faded in over the artwork, and a click-through layer painted over it.
2029
2080
  #### Spectrum
2030
2081
 
2031
2082
  A `spectrum` control is a radio's panadapter and waterfall, drawn by the host
2032
- from sweeps that never reach the extension, on a host with the panadapter
2033
- enabled. It is **extension API 6**, as is `host.setRadioSpectrum`: a bundle
2034
- using either declares `"api": 6`, and an app speaking less refuses it at install
2083
+ from sweeps that never reach the extension. It is **extension API 6**, as is
2084
+ `host.setRadioSpectrum`: a bundle using either declares `"api": 6`, and an app speaking less refuses it at install
2035
2085
  rather than failing to render the scene. It needs `requiresRadioRead`; without it, or on a host or radio
2036
2086
  without a spectrum, the control says why instead. It is placed like a native
2037
2087
  control and takes the fields of `PanelSceneSpectrum` in the SDK:
@@ -2115,7 +2165,9 @@ same shape as `requiresLocation`, and shown to the operator at install:
2115
2165
  `host.setRadioConnection()` and `host.setRadioSpectrum()` — and transmitting
2116
2166
  with them: `host.sendRadioCw()`, `host.playRadioVoice()`, `host.setRadioPtt()`
2117
2167
  and `host.stopRadioTransmit()` — and implies read, since a command answers with the
2118
- radio's state. An undeclared call rejects rather than answering null.
2168
+ radio's state. `"requiresRadioRaw": true` (extension API 11) also allows
2169
+ `host.sendRadioRaw()`, any command in the radio's own protocol, and implies
2170
+ write. An undeclared call rejects rather than answering null.
2119
2171
  A capability belongs to the bundle that declared it, and to the sub-extensions
2120
2172
  that bundle defines under its own key. A separately installed bundle never
2121
2173
  inherits one through its name: `rigpanel-themes` gets nothing from `rigpanel`.
@@ -2231,7 +2283,62 @@ not paced, and not refused for a transmission in progress.
2231
2283
  connection using the same selection and identity checks. Acceptance starts the
2232
2284
  operation; follow reported connection state for completion. It does not switch
2233
2285
  the hardware's mains power or initiate transmission.
2234
- On a host with the panadapter enabled, `readRadio()` carries `spectrum`: null
2286
+
2287
+ `host.sendRadioRaw({id, selection?, text | bytes, replyMs?, until?})`
2288
+ (extension API 11, `requiresRadioRaw`) sends commands in the radio's own
2289
+ protocol — exactly one of `text`, sent as UTF-8, or `bytes`, each 0…255, up
2290
+ to 4096 bytes. **Nothing about them is checked.** They may transmit, change
2291
+ any setting or put the radio somewhere the app's own controls never would;
2292
+ that is what the operator agreed to at install, where the grant is shown in
2293
+ those words. It reaches any local radio through the same `id`/`selection`
2294
+ check as `setRadio`, with keying on or off, and is neither paced nor refused
2295
+ for a transmission in progress. One radio's calls go one at a time, in order,
2296
+ so that one call's reply window never holds the next one's answer, and past
2297
+ eight waiting the next is refused as `busy`; the app's own polls may come
2298
+ between two calls.
2299
+
2300
+ - `replyMs`, up to 2000: how long to wait for the reply. Omitted or 0, the
2301
+ call answers as soon as the commands are handed to the driver, with no
2302
+ `reply`.
2303
+ - `until`, text or bytes, needs `replyMs`: the reply ends just after its first
2304
+ occurrence.
2305
+ - The answer carries `reply: {text, bytes}`, possibly empty, at most 64 KiB,
2306
+ with `text` its UTF-8 reading.
2307
+
2308
+ What the commands are, and what the reply holds, is the protocol's. Where the
2309
+ app's driver keeps one question on the wire and reads the next reply as its
2310
+ answer, a raw command waits its turn in that queue — written beside it, its
2311
+ answer would be read as the answer to the driver's question — or, on a
2312
+ FlexRadio, is numbered as the driver's own are. The reply is then the answers
2313
+ to the raw commands alone, as the radio sent them, once each has come or the
2314
+ window closes:
2315
+
2316
+ | Radio | Commands | Reply |
2317
+ |---|---|---|
2318
+ | Icom CI-V | `bytes`: whole frames, `FE FE` … `FD`, one at a time; the app addresses each to the radio from itself, whatever its address bytes say | each frame's answer frame (`FB`/`FA` or its data) |
2319
+ | Yaesu binary (FT-817/857/897) | `bytes`: five-byte commands | what the radio sends for each before it falls quiet; not read by the app |
2320
+ | FlexRadio | `text`: one command per line, without `C<seq>\|` — the app numbers each like its own | each one's `R` line |
2321
+ | Ham Radio Deluxe | `text`: one command per line, without the radio's context — the app adds it | each one's reply text, one per line |
2322
+ | DXLab Commander | `text`: one whole message (`<command:10>CmdGetFreq<parameters:0>`) | the field it is answered with; without `replyMs` it goes out as a set, waiting for nothing |
2323
+ | flrig | `text`: one XML-RPC call (`<?xml …?><methodCall>…`) — the app posts it | the response's body |
2324
+
2325
+ Every other radio's commands are written as given — several back to back
2326
+ (`FA00014074000;MD2;`) in one write, none of the app's own between them —
2327
+ since each of their answers names what it answers: Kenwood, Elecraft and
2328
+ Yaesu ASCII, rigctld, N3FJP, Guohetec. The reply there is everything the
2329
+ connection received in the window, the answers to the app's own polls
2330
+ included, so `until` is worth setting.
2331
+
2332
+ The app's driver still reads what the radio sends, so a raw command's effect
2333
+ shows in `readRadio` as a change made at the front panel would — and a command
2334
+ that confuses the driver confuses the app. A radio that is not connected
2335
+ answers `disconnected`; one whose driver takes no raw commands (a placeholder
2336
+ radio), `unsupported`; a malformed call, or commands that are not the
2337
+ protocol's (CI-V bytes that are not whole frames), `invalid`. A refusal heard
2338
+ within a second of a raw command on a protocol whose refusals name no command
2339
+ (`?;`, rigctld's `RPRT`) is taken as the raw command's, not counted against the
2340
+ app's own reads.
2341
+ `readRadio()` carries `spectrum` (absent on an app without a panadapter): null
2235
2342
  for a radio without a scope, otherwise its settings as the radio reports them
2236
2343
  (`mode`, `spanHz`, `lowHz`/`highHz`, `refLevelDb`, `speed`), what it accepts
2237
2344
  (`spansHz`, `refMinDb`…`refStepDb`, `modes`, `speeds`) and `problem`, why it
@@ -2422,6 +2529,39 @@ row/entry→`LookupRow` transform. See `DataFileManager`
2422
2529
  (`fetchType: 'raw'` — the parsed Hams of Note index is cached as JSON and
2423
2530
  replayed through `onLoadRawData` at startup, issue #17).
2424
2531
 
2532
+ ### N1MM call history files
2533
+
2534
+ N1MM Logger+'s call history files are uploaded to
2535
+ [n1mmwp.hamdocs.com](https://n1mmwp.hamdocs.com/mmfiles/categories/callhistory/)
2536
+ under a new name with every update (`CWOPS_4002.txt`), and downloaded by a
2537
+ form POST that carries a nonce from the file's own page, so no URL points at
2538
+ the current one. A `url` of `n1mm-history:<stem>` asks the host to find it
2539
+ (extension API 11):
2540
+
2541
+ ```ts
2542
+ { key: 'cwops-history', url: 'n1mm-history:CWOPS', fetchType: 'raw', maxAgeInDays: 7, /* … */ }
2543
+ ```
2544
+
2545
+ - **Which file:** the newest one, by publish date, whose name is the stem or
2546
+ the stem followed by `-` or `_` (extension and case aside). `CWOPS` takes
2547
+ `CWOPS_4002.txt` and `CWOPS_4000-CCC.txt`, never `CWOPSOPEN_2026-002.txt`,
2548
+ though N1MM's search lists all three; to follow one variant only, name it
2549
+ (`n1mm-history:CWOPS_4000-CCC`). The search is read newest first, page by
2550
+ page, until a page lists one of the stem's files.
2551
+ - **When:** N1MM sends no ETag; the chosen file's name, publish date and page
2552
+ stand in for one, so a re-upload under the same name still counts as new. A
2553
+ refresh whose newest file is the one last downloaded stops after the search,
2554
+ like a 304. A forced refresh (RELOAD, Refresh) downloads it regardless.
2555
+ - **What arrives:** the file as served, in N1MM's format — a `!!Order!!,…`
2556
+ column line, `#` comment lines, then CSV rows. `fetchType: 'raw'` is the one
2557
+ that fits; `rawToJSONData` parses it. `url` in its arguments is the
2558
+ `n1mm-history:` string, not the file's address.
2559
+ - **Failures:** a stem nothing matches, a refused download, or a page that no
2560
+ longer has the shape the host reads fails the download with
2561
+ `N1mmCallHistoryUnavailable` — a notice on a refresh the operator asked for,
2562
+ an upload for the background passes. The requests and parsing are
2563
+ `halo_core`'s `n1mm_call_history.dart`, shared by the app and the CLI.
2564
+
2425
2565
  ### How CSV rows reach `csvToLookupEntry`
2426
2566
 
2427
2567
  An extension writes a **per-row** mapper; the kernel synthesizes the batch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -4,9 +4,9 @@ The radio's panadapter and waterfall in a scene panel, with its span, reference
4
4
  level and sweep speed, a local contrast control, and click-to-tune. The host
5
5
  draws the sweeps in the `spectrum` control; they never reach the extension.
6
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.
7
+ Needs a radio whose CAT protocol carries its scope: an Icom IC-705/7300/7610 (at
8
+ 115200 bps on a serial line), or a FlexRadio, which is mirrored through a DAX IQ
9
+ channel.
10
10
 
11
11
  In this repository:
12
12