@ham2k/extension-sdk 0.12.10 → 0.12.12

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/AGENTS.md CHANGED
@@ -81,7 +81,7 @@ will actually call.
81
81
 
82
82
  | Doing this | Read |
83
83
  | --- | --- |
84
- | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-panel-scenes` (experimental interactive panel scene) |
84
+ | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hello-world` (the smallest panel), `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-panel-scenes` (experimental interactive panel scene), `k2hrc-panafall` (panadapter and waterfall scene) |
85
85
  | Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
86
86
  | A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
87
87
  | Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
@@ -6,6 +6,7 @@ const HOOK_CATEGORIES = [
6
6
  "bench",
7
7
  "callNotes",
8
8
  "chasing",
9
+ "cluster",
9
10
  "command",
10
11
  "dataFile",
11
12
  "export",
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.10
1
+ // @ham2k/extension-sdk 0.12.12
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -475,6 +475,7 @@ export declare const HOOK_CATEGORIES: readonly [
475
475
  "bench",
476
476
  "callNotes",
477
477
  "chasing",
478
+ "cluster",
478
479
  "command",
479
480
  "dataFile",
480
481
  "export",
@@ -733,6 +734,39 @@ export interface SpotsHook {
733
734
  postSelfSpot?(args: PostSelfSpotRequest, ctx: HookContext): Promise<PostResult>;
734
735
  postOtherSpot?(args: PostOtherSpotRequest, ctx: HookContext): Promise<PostResult>;
735
736
  }
737
+ export interface ClusterLine {
738
+ seq: number;
739
+ at: number;
740
+ text: string;
741
+ }
742
+ export interface ClusterStatus {
743
+ state: "idle" | "connecting" | "connected" | "failed";
744
+ node: {
745
+ name: string;
746
+ host: string;
747
+ port: number;
748
+ };
749
+ call?: string;
750
+ error?: string;
751
+ message?: string;
752
+ lastSeq: number;
753
+ }
754
+ export interface ClusterReply {
755
+ ok: boolean;
756
+ lines: string[];
757
+ error?: string;
758
+ message?: string;
759
+ }
760
+ export interface ClusterPostSpotRequest {
761
+ freq: number;
762
+ call: string;
763
+ comment?: string;
764
+ }
765
+ export interface ClusterHook {
766
+ onLines(args: {
767
+ lines: ClusterLine[];
768
+ }, ctx: HookContext): Promise<void>;
769
+ }
736
770
  export interface Ref {
737
771
  type: string;
738
772
  ref?: string;
@@ -1219,6 +1253,10 @@ export interface CommandAction {
1219
1253
  comment?: string;
1220
1254
  target?: "self" | "last";
1221
1255
  };
1256
+ autoRespot?: {
1257
+ minutes?: number;
1258
+ off?: boolean;
1259
+ };
1222
1260
  syncAll?: Record<string, never>;
1223
1261
  purgeTests?: Record<string, never>;
1224
1262
  updateSettings?: {
@@ -1480,6 +1518,9 @@ export interface ExtensionManifest {
1480
1518
  requiresLocation?: boolean;
1481
1519
  requiresRadioRead?: boolean;
1482
1520
  requiresRadioWrite?: boolean;
1521
+ requiresClusterRead?: boolean;
1522
+ requiresClusterSpot?: boolean;
1523
+ requiresClusterCommand?: boolean;
1483
1524
  experiments?: string[];
1484
1525
  relevance?: ExtensionRelevance;
1485
1526
  translations?: Record<string, {
@@ -1623,6 +1664,7 @@ export interface FormField {
1623
1664
  templateContext?: "export" | "adif" | "text";
1624
1665
  templateSample?: Record<string, JSONValue>;
1625
1666
  uppercase?: boolean;
1667
+ storedText?: string;
1626
1668
  suggestions?: {
1627
1669
  code: string;
1628
1670
  name?: string;
@@ -2501,6 +2543,20 @@ export declare const host: {
2501
2543
  }): Promise<PanelRadioResult>;
2502
2544
  /** 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. */
2503
2545
  stopRadioTransmit(args: PanelRadioTarget): Promise<PanelRadioResult>;
2546
+ /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
2547
+ clusterStatus(): Promise<ClusterStatus>;
2548
+ /** The node's recent lines after `after`, oldest first, at most `limit` of the newest. Needs `requiresClusterRead` and extension API 10. */
2549
+ clusterLines(args?: {
2550
+ after?: number;
2551
+ limit?: number;
2552
+ }): Promise<ClusterLine[]>;
2553
+ /** Connects if need be and delivers every new line to this extension's `cluster` hook until `clusterUnsubscribe`. Needs `requiresClusterRead` and extension API 10. */
2554
+ clusterSubscribe(): Promise<ClusterStatus>;
2555
+ clusterUnsubscribe(): Promise<ClusterStatus>;
2556
+ /** Posts a spot, logged in as the operator. Needs `requiresClusterSpot` and extension API 10. */
2557
+ clusterPostSpot(args: ClusterPostSpotRequest): Promise<ClusterReply>;
2558
+ /** Sends any one command line, in the operator's name. Needs `requiresClusterCommand` and extension API 10. */
2559
+ clusterSend(command: string): Promise<ClusterReply>;
2504
2560
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
2505
2561
  webSocket(url: string, options?: WebSocketOptions): ExtensionWebSocket;
2506
2562
  kvGet(key: string): Promise<JSONValue | null>;
package/dist/index.js CHANGED
@@ -197,6 +197,29 @@ const host = {
197
197
  async stopRadioTransmit(args) {
198
198
  return await hostCallFn()("stopRadioTransmit", { ...args });
199
199
  },
200
+ /** The app's DX cluster connection, as Settings shows it. Needs `requiresClusterRead` and extension API 10. */
201
+ async clusterStatus() {
202
+ return await hostCallFn()("clusterStatus", {});
203
+ },
204
+ /** The node's recent lines after `after`, oldest first, at most `limit` of the newest. Needs `requiresClusterRead` and extension API 10. */
205
+ async clusterLines(args = {}) {
206
+ return await hostCallFn()("clusterLines", { ...args });
207
+ },
208
+ /** Connects if need be and delivers every new line to this extension's `cluster` hook until `clusterUnsubscribe`. Needs `requiresClusterRead` and extension API 10. */
209
+ async clusterSubscribe() {
210
+ return await hostCallFn()("clusterSubscribe", {});
211
+ },
212
+ async clusterUnsubscribe() {
213
+ return await hostCallFn()("clusterUnsubscribe", {});
214
+ },
215
+ /** Posts a spot, logged in as the operator. Needs `requiresClusterSpot` and extension API 10. */
216
+ async clusterPostSpot(args) {
217
+ return await hostCallFn()("clusterPostSpot", { ...args });
218
+ },
219
+ /** Sends any one command line, in the operator's name. Needs `requiresClusterCommand` and extension API 10. */
220
+ async clusterSend(command) {
221
+ return await hostCallFn()("clusterSend", { command });
222
+ },
200
223
  async fetch(url, options) {
201
224
  return await hostCallFn()("fetch", { url, ...options });
202
225
  },
@@ -39,7 +39,9 @@ function opValues(operation, extra = {}) {
39
39
  station.split(",").map((call) => call.trim().toUpperCase()).filter((call) => call !== "")
40
40
  )
41
41
  ],
42
- operator: operation.operator ?? "",
42
+ // Resolved by the host for this device's position; a template cannot do it
43
+ // itself, because the position never crosses the bridge.
44
+ operator: operation.operatorCall ?? "",
43
45
  // The generated, ref-derived title ("at US-1234") and the operator's own
44
46
  // words, kept apart: an export that withholds private data withholds the
45
47
  // second and keeps the first (see the adif hook's preamble).
@@ -240,7 +240,7 @@ what you want, which is how you use a version the host does not carry, say so:
240
240
  await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
241
241
  ```
242
242
 
243
- `extensions/samples/hello-world` is the shortest complete example. Declaring
243
+ `extensions/samples/k2hrc-hello-world` is the shortest complete example. Declaring
244
244
  what it uses rather than carrying it took the packaged bundle from 185 KB to
245
245
  **4 KB**.
246
246
 
package/docs/forms.md CHANGED
@@ -38,6 +38,10 @@ export interface FormField {
38
38
  value?: any; // initial state value if not provided in host call state
39
39
  placeholder?: string;
40
40
  uppercase?: boolean; // force typed input to uppercase (callsign fields always are)
41
+ // Only used when fieldType === 'secret' in a settings list: what the row
42
+ // says while a value is stored, in place of the fixed mask. A host older
43
+ // than this shows the mask.
44
+ storedText?: string;
41
45
  // Only used when fieldType === 'text': the codes the field takes, offered
42
46
  // in a list under it as the operator types (matched on code and name, as a
43
47
  // `kind: 'options'` logging control matches) and named beside it — a value
package/docs/hooks.md CHANGED
@@ -330,6 +330,48 @@ directly to the POTA API, no auth required). `sota`'s spot API requires an
330
330
  authenticated account app-halo doesn't have yet, so `sota` stays
331
331
  fetch-only for now.
332
332
 
333
+ ## ✅ `cluster` — the DX cluster connection
334
+
335
+ ```ts
336
+ interface ClusterHook {
337
+ onLines(args: { lines: ClusterLine[] }, ctx): Promise<void>
338
+ }
339
+ // ClusterLine: {seq, at, text}
340
+ // host.clusterSubscribe(): Promise<ClusterStatus> // then onLines, until
341
+ // host.clusterUnsubscribe(): Promise<ClusterStatus> // this
342
+ // host.clusterLines({after?, limit?}): Promise<ClusterLine[]>
343
+ // host.clusterStatus(): Promise<ClusterStatus>
344
+ // host.clusterPostSpot({freq, call, comment?}): Promise<ClusterReply>
345
+ // host.clusterSend(command): Promise<ClusterReply>
346
+ // ClusterReply: {ok, lines: string[], error?, message?}
347
+ ```
348
+
349
+ The app holds one telnet connection to the DX cluster node the operator picked
350
+ in Settings, logged in as the operator. Extensions share it rather than open
351
+ their own, because a node logs a call in once
352
+ ([docs/design/cluster.md](https://github.com/ham2k/halo/blob/main/docs/design/cluster.md)). Not on web, where every call
353
+ answers `error: 'unsupported'`. Extension API 10.
354
+
355
+ `clusterPostSpot` is how a `spots` hook posts to the cluster: the host builds
356
+ the `DX` command, sends at most 60 characters of comment, and spaces spots five
357
+ seconds apart. A reply with `ok: true` means the node took the spot without
358
+ refusing it. It does not prove the spot left that node: a DXSpider node can keep
359
+ a self-spot local and still echo it.
360
+
361
+ `onLines` is called only for an extension that called `clusterSubscribe()`,
362
+ with the lines since the last batch, at most once a second. Register the hook
363
+ under the extension's own key (the default): that is the key the host delivers
364
+ to. A subscription lasts until `clusterUnsubscribe()` or the runtime restarts,
365
+ and keeps the session open while it lasts. A reader that only polls
366
+ `clusterLines({ after })` holds nothing open.
367
+
368
+ Three manifest grants gate it, each including the ones before it:
369
+ `"requiresClusterRead": true` for status, lines and subscribing;
370
+ `"requiresClusterSpot": true` adds `clusterPostSpot`;
371
+ `"requiresClusterCommand": true` adds `clusterSend`, which sends any command in
372
+ the operator's name. Declare the least you need: the consent screen names it.
373
+ Implemented by: `ki2d-cluster-spotter` (spot).
374
+
333
375
  ## ✅ `export` — log file generation
334
376
 
335
377
  ```ts
@@ -1070,7 +1112,7 @@ interface CommandHook {
1070
1112
  // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
1071
1113
  // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
1072
1114
  // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
1073
- // setKeyerSpeed?, showKeyer?, setEnterSends?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
1115
+ // setKeyerSpeed?, showKeyer?, setEnterSends?, sendSpots?, autoRespot?, syncAll?, updateSettings?, updateSetting?, openSettings?,
1074
1116
  // updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
1075
1117
  // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
1076
1118
  // devTimeTravel?, toggleExperiment?}
@@ -1656,6 +1698,11 @@ interface PanelHook {
1656
1698
  // | {kind: 'scene', scene, title?, triggers?} (see Panel scene reference)
1657
1699
  ```
1658
1700
 
1701
+ `operation` is the operation's data with its `uuid`, its
1702
+ `startAtMillisMin`/`startAtMillisMax`, and `operatorCall`: who is at the key on
1703
+ this device, resolved for its position in a multi-station operation, and empty
1704
+ when nobody is named.
1705
+
1659
1706
  The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1660
1707
  inside saved layouts — renaming a `key` drops the panel out of every
1661
1708
  arrangement holding it.
package/docs/templates.md CHANGED
@@ -65,7 +65,8 @@ An ISO-8601 UTC string, for the `date` filter: `{{ now | date: '%H:%M' }}`.
65
65
  ### `op` — the operation
66
66
  `station` (the field as typed, which may hold several comma-separated
67
67
  callsigns; `call` is the same value, the name `qso.call` has), `stations`
68
- (that list, uppercased and deduped), `operator`,
68
+ (that list, uppercased and deduped), `operator` (who is at the key on this
69
+ device, empty when nobody is named),
69
70
  `title` (the generated, ref-derived one), `userTitle` (the operator's own
70
71
  words), `grid`, `refs`, `uuid`, `qsoCount`, `date`, `dateCompact`, `time`,
71
72
  `at`, `startDate`, `startTime`, `startAt`, `endDate`, `endTime`, `endAt`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.10",
3
+ "version": "0.12.12",
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
@@ -6,6 +6,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
6
6
 
7
7
  | | |
8
8
  | --- | --- |
9
+ | `k2hrc-hello-world` | The smallest extension there is: one panel, saying hello |
9
10
  | `k2hrc-hamqth` | A callsign lookup, and the `account` hook that asks for a password |
10
11
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
11
12
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
@@ -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,22 @@
1
+ {
2
+ "key": "k2hrc-hello-world",
3
+ "name": "Hello World",
4
+ "shortName": "Hello",
5
+ "version": "1.0.0",
6
+ "description": "The smallest possible Ham2K extension — a panel that says hello",
7
+ "category": "dashboard",
8
+ "icon": "hand-wave-outline",
9
+ "api": 1,
10
+ "keywords": ["sample", "example", "hello"],
11
+ "hooks": ["panel"],
12
+ "sharedDependencies": {
13
+ "@ham2k/lib-callsigns": "^1.0.2",
14
+ "@ham2k/lib-country-files": "^1.0.202605301",
15
+ "@ham2k/lib-dxcc-data": "^1.0.1",
16
+ "@ham2k/lib-format-tools": "^1.1.3",
17
+ "@ham2k/lib-geo-tools": "^1.1.1",
18
+ "@ham2k/lib-operation-data": "^1.1.3",
19
+ "i18next": "^23.16.8",
20
+ "liquidjs": "^10.28.0"
21
+ }
22
+ }
@@ -0,0 +1,53 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // The smallest extension that does something visible: one panel, saying hello.
5
+ //
6
+ // It exists to be COPIED. Everything here is the minimum — one hook, one
7
+ // descriptor, one render — so that what a real extension adds is obvious by
8
+ // comparison. See the catalog's `ham2k-custom-text` for a panel that earns its
9
+ // keep, and docs/extensions/hooks.md for every other hook there is.
10
+ //
11
+ // Its manifest still lists the host's shared libraries in `sharedDependencies`:
12
+ // the SDK reaches them, and declaring them makes the bundle use the host's
13
+ // copy rather than carry its own — see docs/extensions/distribution.md.
14
+
15
+ import { defineExtension } from "@ham2k/extension-sdk"
16
+ import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
17
+
18
+ import manifest from "../manifest.json"
19
+
20
+ const HelloPanel: PanelHook = {
21
+ /// What the operator sees when choosing a panel to place.
22
+ async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
23
+ return [
24
+ {
25
+ key: "hello",
26
+ title: "Hello World",
27
+ description: "A panel that says hello",
28
+ icon: "hand-wave-outline",
29
+ },
30
+ ]
31
+ },
32
+
33
+ /// What the pane shows. Markdown, rendered by the app — a panel returns
34
+ /// content, never widgets, which is what keeps extensions out of the UI
35
+ /// toolkit's way.
36
+ ///
37
+ /// The method is `render`, not `renderPanel`: the host routes to the hook by
38
+ /// its registered key and then calls this by name, so a near-miss is a panel
39
+ /// that installs, activates, and then renders nothing.
40
+ async render(_args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
41
+ return {
42
+ kind: "markdown",
43
+ content: ["# Hello, World!", "", "This panel came from an extension installed from a `.h2kext` file."].join("\n"),
44
+ }
45
+ },
46
+ }
47
+
48
+ defineExtension({
49
+ ...manifest,
50
+ onActivation({ registerHook }) {
51
+ registerHook("panel", { key: "k2hrc-hello-world", hook: HelloPanel })
52
+ },
53
+ })