@ham2k/extension-sdk 0.3.0 → 0.5.0

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) |
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-svg-scenes` (experimental interactive SVG) |
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>` |
package/README.md CHANGED
@@ -53,6 +53,26 @@ offline and describe the version installed here rather than whatever the
53
53
  repository says today. If an agent is writing your extension, point it at
54
54
  `node_modules/@ham2k/extension-sdk/AGENTS.md` — `h2kext-init` does that for you.
55
55
 
56
+ ## Export types and settings
57
+
58
+ Export hooks can register shared types with `getExportTypes()`. Use
59
+ `exportTypeDefinition(activationType, format, label)` for an identity shared
60
+ by extensions serving the same activity. `exportType` identifies the settings;
61
+ `exportKey` identifies an individual file, such as one park in an activation.
62
+
63
+ The `activityExportHook` and `huntingExportHook` helpers register their types
64
+ and forward export settings to the ADIF generator. Reference activity types
65
+ use `templateCategory: 'reference'`; other types use the other-activity defaults.
66
+ `GlobalExportSettings` provides separate normal filename, compact filename,
67
+ and title templates for each category, plus shared ADIF field templates.
68
+
69
+ When updating an exporter from SDK 0.3, keep a reference-specific identifier
70
+ in `exportKey`, register a stable `exportType`, and forward `exportSettings`,
71
+ `exportData`, `exportTitle`, and `includeLookupData` when delegating generation.
72
+ Per-type custom templates override inherited defaults only when enabled.
73
+ See `docs/hooks.md` §`export` for the complete contract and `docs/forms.md`
74
+ for the `textTemplate` form field.
75
+
56
76
  ## About the peer dependencies
57
77
 
58
78
  This package lists the libraries the extension host carries — `@ham2k/lib-*`,
@@ -1,3 +1,4 @@
1
+ import { exportTypeDefinition } from "./exportSettings.js";
1
2
  import { exportFilename, startMillisOf } from "./exportNames.js";
2
3
  import { segmentsWith } from "./segments.js";
3
4
  import { createCachedTranslator } from "./i18n.js";
@@ -21,11 +22,23 @@ function filenameFor(operation, qsos, ref, compact) {
21
22
  function activityExportHook(rules) {
22
23
  const prefix = `${rules.key}-adif`;
23
24
  return {
25
+ async getExportTypes() {
26
+ return [{
27
+ ...exportTypeDefinition(rules.activationType, "adif", rules.label),
28
+ templateCategory: "reference",
29
+ templateSample: rules.templateSample ?? {
30
+ log: { ref: "REF-1234", refName: "Example Reference" },
31
+ operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
32
+ }
33
+ }];
34
+ },
24
35
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
25
36
  const refs = refsOfType(operation, rules.activationType);
26
37
  const t = tFor(ctx);
27
38
  return refs.map((ref) => ({
28
- exportType: `${prefix}${SEPARATOR}${ref.ref}`,
39
+ exportType: `${rules.activationType}-adif`,
40
+ templateData: { ref: ref.ref, refName: ref.name ?? "", activity: rules.label },
41
+ exportKey: `${prefix}${SEPARATOR}${ref.ref}`,
29
42
  format: "adif",
30
43
  // The FORMAT leads: a park activated on a contest weekend offers
31
44
  // several files for the same reference, and "POTA US-1234" alone said
@@ -44,10 +57,11 @@ function activityExportHook(rules) {
44
57
  },
45
58
  async generateExport(args, ctx) {
46
59
  const exportType = String(args.exportType ?? "");
47
- if (!exportType.startsWith(`${prefix}${SEPARATOR}`)) {
48
- throw new Error(`${rules.key}: unknown export type '${exportType}'`);
60
+ const exportKey = String(args.exportKey ?? "");
61
+ if (exportType !== `${rules.activationType}-adif` || !exportKey.startsWith(`${prefix}${SEPARATOR}`)) {
62
+ throw new Error(`${rules.key}: unknown export type/key '${exportType}' / '${exportKey}'`);
49
63
  }
50
- const wanted = exportType.slice(prefix.length + SEPARATOR.length);
64
+ const wanted = exportKey.slice(prefix.length + SEPARATOR.length);
51
65
  if (!refsOfType(args.operation, rules.activationType).some((r) => r.ref === wanted)) {
52
66
  throw new Error(`${rules.key}: operation has no ${rules.activationType} reference '${wanted}'`);
53
67
  }
@@ -62,6 +76,10 @@ function activityExportHook(rules) {
62
76
  qsos: args.qsos,
63
77
  segments: segmentsWith(args.segments, claimOnly),
64
78
  includePrivateData: args.includePrivateData,
79
+ includeLookupData: args.includeLookupData,
80
+ exportSettings: args.exportSettings,
81
+ exportData: args.exportData,
82
+ exportTitle: args.exportTitle,
65
83
  mainHandler: rules.key,
66
84
  includeFieldsFrom: rules.includeFieldsFrom
67
85
  });
@@ -83,17 +101,22 @@ function filenameForHunt(operation, qsos, activity, compact) {
83
101
  });
84
102
  }
85
103
  function huntingExportHook(rules) {
86
- const exportType = `${rules.key}-hunter`;
104
+ const exportType = `${rules.huntingType}-hunter`;
87
105
  function huntedQsos(qsos) {
88
106
  return qsos.filter((qso) => refsOfType(qso, rules.huntingType).length > 0);
89
107
  }
90
108
  return {
109
+ async getExportTypes() {
110
+ return [{ exportType, format: "adif", label: `${rules.label} Hunter Log` }];
111
+ },
91
112
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
92
113
  if (refsOfType(operation, rules.activationType).length > 0) return [];
93
114
  if (huntedQsos(qsos ?? []).length === 0) return [];
94
115
  const t = tFor(ctx);
95
116
  return [{
96
117
  exportType,
118
+ qsoCount: huntedQsos(qsos ?? []).length,
119
+ templateData: { activity: rules.label },
97
120
  format: "adif",
98
121
  label: t("adifForHunter", { program: rules.label }),
99
122
  filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
@@ -119,6 +142,10 @@ function huntingExportHook(rules) {
119
142
  qsos: huntedQsos(args.qsos ?? []),
120
143
  segments: args.segments,
121
144
  includePrivateData: args.includePrivateData,
145
+ includeLookupData: args.includeLookupData,
146
+ exportSettings: args.exportSettings,
147
+ exportData: args.exportData,
148
+ exportTitle: args.exportTitle,
122
149
  // The hunter file is submitted to ONE program too — see
123
150
  // `activityExportHook.generateExport`. `rules.key` is the ACTIVITY's
124
151
  // key ('pota'), which is what its `adifFields` hook registers under;
@@ -136,7 +163,7 @@ function huntingExportHook(rules) {
136
163
  }
137
164
  };
138
165
  }
139
- async function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }) {
166
+ async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
140
167
  const { hooks } = await import("@ham2k/extension-sdk");
141
168
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
142
169
  operation,
@@ -146,6 +173,10 @@ async function adifForExport({ operation, qsos, segments, includePrivateData, ma
146
173
  // Forwarded, or the delegate defaults to withholding and the user's
147
174
  // "include private info" setting silently does nothing here.
148
175
  includePrivateData,
176
+ includeLookupData,
177
+ exportSettings,
178
+ exportData,
179
+ exportTitle,
149
180
  mainHandler,
150
181
  includeFieldsFrom
151
182
  });
@@ -0,0 +1,133 @@
1
+ import { startMillisOf } from "./exportNames.js";
2
+ import { templateContext } from "./templateContext.js";
3
+ import { oneLine, renderTemplate } from "./templates.js";
4
+ import { isTestOperation } from "./testOperation.js";
5
+ const EXPORT_DEFAULTS = {
6
+ includePrivateData: false,
7
+ includeLookupData: true,
8
+ customTemplates: false,
9
+ filenameTemplate: "{{ op.date }} {{ log.station }}{% if log.ref != blank %} at {{ log.ref }}{% elsif log.activity != blank %} for {{ log.activity }}{% endif %} {{ log.modifier }}",
10
+ compactFilenameTemplate: "{{ log.station | dash }}{% if log.ref != blank %}@{{ log.ref | dash }}{% elsif log.activity != blank %}-{{ log.activity | downcase | dash }}{% endif %}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
11
+ titleTemplate: "{{ log.station }}{% if log.ref != blank %}: {{ log.activity }} at {{ log.ref }} {{ log.refName }}{% elsif log.activity != blank %}: {{ log.activity }}{% endif %} on {{ op.date }}",
12
+ adifNotesTemplate: "{{ qso.notes }}",
13
+ adifCommentTemplate: "{{ qso.notes }}",
14
+ adifQslMessageTemplate: ""
15
+ };
16
+ const GLOBAL_EXPORT_DEFAULTS = {
17
+ ...EXPORT_DEFAULTS,
18
+ referenceFilenameTemplate: "{{ op.date }} {{ log.station }} at {{ log.ref }} {{ log.modifier }}",
19
+ referenceCompactFilenameTemplate: "{{ log.station | dash }}@{{ log.ref | dash }}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
20
+ referenceTitleTemplate: "{{ log.station }}: {{ log.activity }} at {{ log.ref }} {{ log.refName }} on {{ op.date }}",
21
+ otherFilenameTemplate: "{{ op.date }} {{ log.station }}{% if log.activity != blank %} for {{ log.activity }}{% endif %} {{ log.modifier }}",
22
+ otherCompactFilenameTemplate: "{{ log.station | dash }}{% if log.activity != blank %}-{{ log.activity | downcase | dash }}{% endif %}-{{ op.dateCompact }}{% if log.modifier != blank %}-{{ log.modifier | downcase | dash }}{% endif %}",
23
+ otherTitleTemplate: "{{ log.station }}{% if log.activity != blank %}: {{ log.activity }}{% endif %} on {{ op.date }}"
24
+ };
25
+ function resolveGlobalExportSettings(global = {}) {
26
+ const resolved = { ...GLOBAL_EXPORT_DEFAULTS, ...global };
27
+ for (const prefix of ["reference", "other"]) {
28
+ for (const suffix of ["FilenameTemplate", "CompactFilenameTemplate", "TitleTemplate"]) {
29
+ const key = `${prefix}${suffix}`;
30
+ const legacy = `${suffix[0].toLowerCase()}${suffix.slice(1)}`;
31
+ if (global[key] === void 0 && typeof global[legacy] === "string") {
32
+ Object.assign(resolved, { [key]: global[legacy] });
33
+ }
34
+ }
35
+ }
36
+ return resolved;
37
+ }
38
+ function exportTypeDefinition(activationType, format, label, defaults) {
39
+ return { exportType: `${activationType}-${format}`, activationType, format, label, defaults };
40
+ }
41
+ const templateKeys = [
42
+ "filenameTemplate",
43
+ "compactFilenameTemplate",
44
+ "titleTemplate",
45
+ "adifNotesTemplate",
46
+ "adifCommentTemplate",
47
+ "adifQslMessageTemplate"
48
+ ];
49
+ function resolveExportSettings(definition, global = {}, overrides = {}) {
50
+ const defaults = resolveGlobalExportSettings(global);
51
+ const reference = definition.templateCategory === "reference";
52
+ const resolved = {
53
+ includePrivateData: defaults.includePrivateData,
54
+ includeLookupData: defaults.includeLookupData,
55
+ adifNotesTemplate: defaults.adifNotesTemplate,
56
+ adifCommentTemplate: defaults.adifCommentTemplate,
57
+ adifQslMessageTemplate: defaults.adifQslMessageTemplate,
58
+ filenameTemplate: reference ? defaults.referenceFilenameTemplate : defaults.otherFilenameTemplate,
59
+ compactFilenameTemplate: reference ? defaults.referenceCompactFilenameTemplate : defaults.otherCompactFilenameTemplate,
60
+ titleTemplate: reference ? defaults.referenceTitleTemplate : defaults.otherTitleTemplate,
61
+ ...definition.defaults
62
+ };
63
+ for (const key of ["includePrivateData", "includeLookupData"]) {
64
+ if (typeof overrides[key] === "boolean") resolved[key] = overrides[key];
65
+ }
66
+ resolved.customTemplates = overrides.customTemplates === true;
67
+ if (resolved.customTemplates) {
68
+ for (const key of templateKeys) {
69
+ if (typeof overrides[key] === "string") resolved[key] = overrides[key];
70
+ }
71
+ }
72
+ if (definition.format !== "adif") {
73
+ delete resolved.includePrivateData;
74
+ delete resolved.includeLookupData;
75
+ for (const key of templateKeys.slice(2)) delete resolved[key];
76
+ }
77
+ return resolved;
78
+ }
79
+ function exportQso(qso, settings) {
80
+ const copy = { ...qso };
81
+ for (const side of ["their", "our"]) {
82
+ if (copy[side] && typeof copy[side] === "object" && !Array.isArray(copy[side])) {
83
+ const station = { ...copy[side] };
84
+ if (settings.includeLookupData === false) delete station.guess;
85
+ if (settings.includePrivateData === false) {
86
+ for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete station[key];
87
+ if (typeof station.grid === "string") station.grid = station.grid.slice(0, 6);
88
+ if (station.guess && typeof station.guess === "object") {
89
+ const guess = { ...station.guess };
90
+ for (const key of ["name", "city", "email", "notes", "lat", "lon", "multiIdentifier"]) delete guess[key];
91
+ if (typeof guess.grid === "string") guess.grid = guess.grid.slice(0, 6);
92
+ station.guess = guess;
93
+ }
94
+ }
95
+ copy[side] = station;
96
+ }
97
+ }
98
+ if (settings.includePrivateData === false) delete copy.notes;
99
+ return copy;
100
+ }
101
+ function prepareExportOption(option, operation, qsos, compact) {
102
+ const settings = option.exportSettings;
103
+ const extension = { adif: "adi", cabrillo: "log", reg1test: "edi" }[option.format] ?? option.filename?.split(".").pop() ?? "txt";
104
+ const data = {
105
+ station: String(operation.stationCall ?? ""),
106
+ ref: "",
107
+ activity: "",
108
+ modifier: "",
109
+ ...option.templateData,
110
+ exportType: option.exportType,
111
+ format: option.format,
112
+ extension,
113
+ compact
114
+ };
115
+ if (isTestOperation(operation.stationCall)) data.modifier = [data.modifier, "Testing"].filter(Boolean).join(" ");
116
+ const context = templateContext({ operation, qsoCount: option.qsoCount ?? qsos.length, atMillis: startMillisOf(operation, qsos) ?? Date.now(), log: data });
117
+ const template = compact ? settings.compactFilenameTemplate : settings.filenameTemplate;
118
+ const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
119
+ const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
120
+ const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
121
+ const titleContext = settings.includePrivateData === false ? { ...context, op: { ...context.op, title: "", userTitle: "", userNotes: "", grid: String(context.op.grid ?? "").slice(0, 6) }, log: { ...context.log, modifier: "" } } : context;
122
+ const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
123
+ return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
124
+ }
125
+ export {
126
+ EXPORT_DEFAULTS,
127
+ GLOBAL_EXPORT_DEFAULTS,
128
+ exportQso,
129
+ exportTypeDefinition,
130
+ prepareExportOption,
131
+ resolveExportSettings,
132
+ resolveGlobalExportSettings
133
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,121 @@
1
- // @ham2k/extension-sdk 0.3.0
1
+ // @ham2k/extension-sdk 0.5.0
2
+ /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
+ export interface SvgScene {
4
+ version: 1;
5
+ width: number;
6
+ height: number;
7
+ values: Record<string, number>;
8
+ layers: SvgSceneLayer[];
9
+ controls?: SvgSceneControl[];
10
+ }
11
+ /** A clamped, linear mapping. No expressions or extension code run per frame. */
12
+ export interface SceneBinding {
13
+ value: string;
14
+ /** Transform the source before mapping (for example selecting a display digit). */
15
+ scale?: number;
16
+ truncate?: boolean;
17
+ modulo?: number;
18
+ input: [
19
+ number,
20
+ number
21
+ ];
22
+ output: [
23
+ number,
24
+ number
25
+ ];
26
+ /** Optional equally spaced output samples over input; replaces the output ramp. */
27
+ samples?: number[];
28
+ }
29
+ export interface SvgSceneLayer {
30
+ id: string;
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ /** Supply exactly one of svg or text. SVG must be self-contained. */
36
+ svg?: string;
37
+ /** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
38
+ * Size is unscaled; the host applies OS text scaling once. */
39
+ text?: {
40
+ value?: string;
41
+ literal?: string;
42
+ prefix?: string;
43
+ suffix?: string;
44
+ decimals?: number;
45
+ size?: number;
46
+ color?: string;
47
+ samples?: (number | string)[];
48
+ /** Multiply numeric text values before formatting. Defaults to 1. */
49
+ scale?: number;
50
+ /** Numeric formatting after scaling: truncate, then modulo, then zero padding. */
51
+ truncate?: boolean;
52
+ modulo?: number;
53
+ minIntegerDigits?: number;
54
+ fontFamily?: string;
55
+ fontWeight?: number;
56
+ lineHeight?: number;
57
+ letterSpacing?: number;
58
+ /** Horizontal alignment within the layer. Defaults to start. */
59
+ align?: "start" | "center" | "end";
60
+ };
61
+ rotation?: SceneBinding;
62
+ translateX?: SceneBinding;
63
+ translateY?: SceneBinding;
64
+ opacity?: SceneBinding;
65
+ /** Rotation origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
66
+ pivot?: [
67
+ number,
68
+ number
69
+ ];
70
+ transitionMs?: number;
71
+ /** Local deterministic flicker/pulse; pauses when hidden, disabled for reduced motion. */
72
+ pulse?: {
73
+ periodMs: number;
74
+ minOpacity: number;
75
+ flicker?: boolean;
76
+ };
77
+ }
78
+ export interface SvgSceneControl {
79
+ id: string;
80
+ label: string;
81
+ kind: "button" | "slider" | "knob";
82
+ /** A button can open a native dropdown. Picking an item sends its event as the action. */
83
+ menu?: {
84
+ label: string;
85
+ event: string;
86
+ }[];
87
+ x: number;
88
+ y: number;
89
+ width: number;
90
+ height: number;
91
+ /** Omit event for local-only interaction (for example a chart inspection cursor). */
92
+ event?: string;
93
+ /** Send coalesced change events during dragging, plus the final commit. */
94
+ continuous?: boolean;
95
+ value?: string;
96
+ min?: number;
97
+ max?: number;
98
+ step?: number;
99
+ /** Equal-width buckets, with one bucket per step (useful for hourly charts). */
100
+ discrete?: boolean;
101
+ /** Inspect on hover. Only allowed on local-only sliders; touch/keyboard also work. */
102
+ hover?: boolean;
103
+ /** Accessible descriptions indexed by (value - min) / step. */
104
+ valueLabels?: string[];
105
+ /** Knobs use relative horizontal/upward dragging. Default: step per scene unit. */
106
+ sensitivity?: number;
107
+ }
108
+ export interface PanelSceneEvent {
109
+ controlId: string;
110
+ action: string;
111
+ phase: "activate" | "change" | "commit";
112
+ sequence: number;
113
+ value?: number;
114
+ }
115
+ /** Patch only named scene values. The host ignores stale responses. */
116
+ export interface PanelSceneEventResult {
117
+ values: Record<string, number>;
118
+ }
2
119
  export type CallInfo = {
3
120
  call: string;
4
121
  baseCall?: string;
@@ -388,11 +505,44 @@ export interface AdifImportHook {
388
505
  }[];
389
506
  }, ctx: HookContext): Promise<(AdifImportResult | null)[]>;
390
507
  }
508
+ export interface ExportSettings {
509
+ includePrivateData?: boolean;
510
+ includeLookupData?: boolean;
511
+ customTemplates?: boolean;
512
+ filenameTemplate?: string;
513
+ compactFilenameTemplate?: string;
514
+ titleTemplate?: string;
515
+ adifNotesTemplate?: string;
516
+ adifCommentTemplate?: string;
517
+ adifQslMessageTemplate?: string;
518
+ }
519
+ export interface GlobalExportSettings extends ExportSettings {
520
+ referenceFilenameTemplate?: string;
521
+ referenceCompactFilenameTemplate?: string;
522
+ referenceTitleTemplate?: string;
523
+ otherFilenameTemplate?: string;
524
+ otherCompactFilenameTemplate?: string;
525
+ otherTitleTemplate?: string;
526
+ }
527
+ export interface ExportTypeDefinition {
528
+ exportType: string;
529
+ label: string;
530
+ format: string;
531
+ activationType?: string;
532
+ templateCategory?: "reference" | "other";
533
+ defaults?: ExportSettings;
534
+ templateSample?: Record<string, JSONValue>;
535
+ }
391
536
  export interface ExportRequest {
537
+ exportSettings?: ExportSettings;
538
+ exportData?: Record<string, JSONValue>;
539
+ exportTitle?: string;
540
+ includeLookupData?: boolean;
392
541
  operation: Record<string, JSONValue>;
393
542
  qsos: Record<string, JSONValue>[];
394
543
  segments?: OperationSegmentPayload[];
395
544
  exportType?: string;
545
+ exportKey?: string;
396
546
  compactFilenames?: boolean;
397
547
  includePrivateData?: boolean;
398
548
  mainHandler?: string;
@@ -409,7 +559,10 @@ export interface ExportOptionsRequest {
409
559
  compactFilenames?: boolean;
410
560
  }
411
561
  export interface ExportOption {
562
+ qsoCount?: number;
563
+ templateData?: Record<string, JSONValue>;
412
564
  exportType: string;
565
+ exportKey?: string;
413
566
  format: string;
414
567
  label: string;
415
568
  filename?: string;
@@ -421,6 +574,7 @@ export interface ExportOption {
421
574
  refType?: string;
422
575
  }
423
576
  export interface ExportHook {
577
+ getExportTypes?(args: Record<string, never>, ctx: HookContext): Promise<ExportTypeDefinition[]>;
424
578
  suggestExportOptions?(args: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
425
579
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
426
580
  }
@@ -770,6 +924,8 @@ export interface ExtensionManifest {
770
924
  domains?: string[];
771
925
  allowUserAgentOverride?: boolean;
772
926
  requiresLocation?: boolean;
927
+ requiresRadioRead?: boolean;
928
+ requiresRadioWrite?: boolean;
773
929
  experiments?: string[];
774
930
  relevance?: ExtensionRelevance;
775
931
  translations?: Record<string, {
@@ -871,7 +1027,7 @@ export interface DataFileDefinition {
871
1027
  onLoadRawData?: (data: any) => void;
872
1028
  onRemoveRawData?: () => Promise<void>;
873
1029
  }
874
- export type FormFieldType = "text" | "multiline" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
1030
+ export type FormFieldType = "text" | "multiline" | "textTemplate" | "email" | "callsign" | "number" | "select" | "radio" | "checkbox" | "secret" | "list" | "multiselect" | "account";
875
1031
  export interface FormFieldOption {
876
1032
  label: string;
877
1033
  value: any;
@@ -885,6 +1041,8 @@ export interface FormField {
885
1041
  label: string;
886
1042
  value?: any;
887
1043
  placeholder?: string;
1044
+ templateContext?: "export" | "adif" | "text";
1045
+ templateSample?: Record<string, JSONValue>;
888
1046
  uppercase?: boolean;
889
1047
  options?: FormFieldOption[] | string;
890
1048
  validate?: (value: any, state: Record<string, any>) => string | null | undefined | Promise<string | null | undefined>;
@@ -934,6 +1092,8 @@ export interface FormMarkdownBlock {
934
1092
  title?: string;
935
1093
  }
936
1094
  export interface FormActionElement {
1095
+ style?: "row";
1096
+ icon?: string;
937
1097
  type: "action";
938
1098
  key: string;
939
1099
  label: string;
@@ -1081,7 +1241,75 @@ export interface PanelDescriptor {
1081
1241
  multiple?: boolean;
1082
1242
  form?: SettingsField[];
1083
1243
  }
1244
+ /** Resolved host typography; sizes are logical pixels, not the app Font Scale. */
1245
+ export interface PanelTypography {
1246
+ fontFamily: string | null;
1247
+ fontFamilyFallback: string[];
1248
+ fontSize: number;
1249
+ /** OS text scaling at this exact role size (the scaler may be nonlinear). */
1250
+ scaledFontSize: number;
1251
+ fontWeight: number;
1252
+ lineHeight: number;
1253
+ letterSpacing: number;
1254
+ }
1255
+ /** Current placement environment. Never multiply coordinates by devicePixelRatio. */
1256
+ export interface PanelEnvironment {
1257
+ version: 1;
1258
+ width: number;
1259
+ height: number;
1260
+ safeInsets: {
1261
+ left: number;
1262
+ top: number;
1263
+ right: number;
1264
+ bottom: number;
1265
+ };
1266
+ brightness: "light" | "dark";
1267
+ colors: Record<"surface" | "surfaceContainer" | "onSurface" | "onSurfaceVariant" | "accent" | "primary" | "onPrimary" | "secondary" | "outline" | "outlineVariant" | "error" | "onError", string>;
1268
+ typography: Record<"body" | "label" | "title" | "display" | "mono", PanelTypography>;
1269
+ locale: string;
1270
+ textDirection: "ltr" | "rtl";
1271
+ devicePixelRatio: number;
1272
+ reducedMotion: boolean;
1273
+ highContrast: boolean;
1274
+ }
1275
+ /** A local CAT radio. Values are reported readings, never manual VFO fallbacks. */
1276
+ export interface PanelRadioState {
1277
+ id: string | null;
1278
+ name: string | null;
1279
+ status: "disconnected" | "connecting" | "connected" | "disconnecting" | "error";
1280
+ stale: boolean;
1281
+ problem: string | null;
1282
+ frequencyHz: number | null;
1283
+ mode: string | null;
1284
+ powerWatts: number | null;
1285
+ transmitting: boolean | null;
1286
+ meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
1287
+ canTune: boolean;
1288
+ }
1289
+ export interface PanelRadioTune {
1290
+ /** Reject if this identity no longer matches the selected radio. */
1291
+ id: string;
1292
+ /** Pin a local radio by ID; omitted means follow the designated radio. */
1293
+ selection?: string;
1294
+ frequencyHz?: number;
1295
+ mode?: "LSB" | "USB" | "CW" | "AM" | "FM";
1296
+ }
1297
+ export interface PanelRadioTuneResult {
1298
+ /** Accepted by the host; confirmation still comes from reported state. */
1299
+ accepted: boolean;
1300
+ reason?: "changed" | "disconnected" | "transmitting" | "invalid";
1301
+ state: PanelRadioState | null;
1302
+ }
1084
1303
  export interface PanelRenderArgs {
1304
+ /** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
1305
+ environment?: PanelEnvironment;
1306
+ /** Display time follows developer time travel; real time is for network retry/cache budgets. */
1307
+ clock?: {
1308
+ nowMillis: number;
1309
+ realNowMillis: number;
1310
+ };
1311
+ /** Stable placement identity, supplied by scene-capable hosts. */
1312
+ instanceId?: string;
1085
1313
  panelKey: string;
1086
1314
  operation: Record<string, JSONValue>;
1087
1315
  qso?: Record<string, JSONValue>;
@@ -1090,6 +1318,11 @@ export interface PanelRenderArgs {
1090
1318
  reason: string;
1091
1319
  }
1092
1320
  export type PanelContent = {
1321
+ kind: "svgScene";
1322
+ scene: SvgScene;
1323
+ title?: string;
1324
+ triggers?: string[];
1325
+ } | {
1093
1326
  kind: "markdown";
1094
1327
  content: string;
1095
1328
  title?: string;
@@ -1108,6 +1341,9 @@ export type PanelContent = {
1108
1341
  export interface PanelHook {
1109
1342
  getPanels(args: Record<string, never>, ctx: HookContext): Promise<PanelDescriptor[]>;
1110
1343
  render(args: PanelRenderArgs, ctx: HookContext): Promise<PanelContent>;
1344
+ onEvent?(args: PanelRenderArgs & {
1345
+ event: PanelSceneEvent;
1346
+ }, ctx: HookContext): Promise<PanelSceneEventResult>;
1111
1347
  }
1112
1348
  /**
1113
1349
  * The standard per-extension translator: takes the extension's i18next
@@ -1235,12 +1471,14 @@ export type ActivityScoresheet = {
1235
1471
  export declare function activityScorer(rules: ActivityScoringRules): ContestScorer<ActivityScoresheet>;
1236
1472
  export interface ActivityExportRules {
1237
1473
  key: string;
1474
+ templateSample?: Record<string, JSONValue>;
1238
1475
  label: string;
1239
1476
  activationType: string;
1240
1477
  icon?: string;
1241
1478
  includeFieldsFrom?: string[];
1242
1479
  }
1243
1480
  export declare function activityExportHook(rules: ActivityExportRules): {
1481
+ getExportTypes(): Promise<ExportTypeDefinition[]>;
1244
1482
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1245
1483
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1246
1484
  };
@@ -1254,14 +1492,19 @@ export interface HuntingExportRules {
1254
1492
  includeFieldsFrom?: string[];
1255
1493
  }
1256
1494
  export declare function huntingExportHook(rules: HuntingExportRules): {
1495
+ getExportTypes(): Promise<ExportTypeDefinition[]>;
1257
1496
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1258
1497
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1259
1498
  };
1260
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, mainHandler, includeFieldsFrom }: {
1499
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
1261
1500
  operation: Record<string, JSONValue>;
1262
1501
  qsos: Record<string, JSONValue>[];
1263
1502
  segments?: OperationSegmentPayload[];
1264
1503
  includePrivateData?: boolean;
1504
+ includeLookupData?: boolean;
1505
+ exportSettings?: ExportSettings;
1506
+ exportData?: Record<string, JSONValue>;
1507
+ exportTitle?: string;
1265
1508
  mainHandler: string;
1266
1509
  includeFieldsFrom?: string[];
1267
1510
  }): Promise<string>;
@@ -1284,6 +1527,19 @@ export interface ExportNameParts {
1284
1527
  }
1285
1528
  export declare function exportFilename(parts: ExportNameParts): string;
1286
1529
  export declare function startMillisOf(operation: Record<string, JSONValue>, qsos?: Record<string, JSONValue>[]): number | undefined;
1530
+ export declare const EXPORT_DEFAULTS: ExportSettings;
1531
+ export declare const GLOBAL_EXPORT_DEFAULTS: GlobalExportSettings;
1532
+ export declare function resolveGlobalExportSettings(global?: GlobalExportSettings): GlobalExportSettings;
1533
+ export declare function exportTypeDefinition(activationType: string, format: string, label: string, defaults?: ExportSettings): ExportTypeDefinition;
1534
+ export declare function resolveExportSettings(definition: ExportTypeDefinition, global?: GlobalExportSettings, overrides?: ExportSettings): ExportSettings;
1535
+ export declare function exportQso(qso: Record<string, JSONValue>, settings: ExportSettings): Record<string, JSONValue>;
1536
+ export declare function prepareExportOption(option: ExportOption & {
1537
+ exportSettings: ExportSettings;
1538
+ }, operation: Record<string, JSONValue>, qsos: Record<string, JSONValue>[], compact: boolean): ExportOption & {
1539
+ exportSettings: ExportSettings;
1540
+ exportTitle: string;
1541
+ exportData: Record<string, JSONValue>;
1542
+ };
1287
1543
  export type RefTransform = {
1288
1544
  pattern: string;
1289
1545
  replacement: string;
@@ -1397,6 +1653,14 @@ export declare const hooks: {
1397
1653
  }[]>;
1398
1654
  };
1399
1655
  export declare const host: {
1656
+ listRadios(): Promise<PanelRadioState[]>;
1657
+ readRadio(id?: string): Promise<PanelRadioState | null>;
1658
+ setRadioConnection(args: {
1659
+ id: string;
1660
+ selection?: string;
1661
+ connected: boolean;
1662
+ }): Promise<PanelRadioTuneResult>;
1663
+ tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
1400
1664
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
1401
1665
  kvGet(key: string): Promise<JSONValue | null>;
1402
1666
  getLocation(): Promise<DeviceLocation | null>;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
+ export * from "./svgScene.js";
2
3
  export * from "./i18n.js";
3
4
  export * from "./dxcc.js";
4
5
  export * from "./location.js";
@@ -9,6 +10,7 @@ export * from "./activityScoring.js";
9
10
  export * from "./activityExports.js";
10
11
  export * from "./activityAdifImport.js";
11
12
  export * from "./exportNames.js";
13
+ export * from "./exportSettings.js";
12
14
  export * from "./referenceActivity.js";
13
15
  export * from "./refTransforms.js";
14
16
  export * from "./templates.js";
@@ -59,6 +61,18 @@ const hooks = {
59
61
  }
60
62
  };
61
63
  const host = {
64
+ async listRadios() {
65
+ return await hostCallFn()("listRadios", {});
66
+ },
67
+ async readRadio(id) {
68
+ return await hostCallFn()("readRadio", { ...id ? { id } : {} });
69
+ },
70
+ async setRadioConnection(args) {
71
+ return await hostCallFn()("setRadioConnection", { ...args });
72
+ },
73
+ async tuneRadio(args) {
74
+ return await hostCallFn()("tuneRadio", { ...args });
75
+ },
62
76
  async fetch(url, options) {
63
77
  return await hostCallFn()("fetch", { url, ...options });
64
78
  },
File without changes
package/docs/forms.md CHANGED
@@ -14,6 +14,7 @@ All types are defined in `extensions/sdk/src/types.ts`.
14
14
  export type FormFieldType =
15
15
  | 'text'
16
16
  | 'multiline'
17
+ | 'textTemplate'
17
18
  | 'email'
18
19
  | 'callsign'
19
20
  | 'number'
@@ -70,8 +71,8 @@ export interface FormField {
70
71
  // fields — a whole section header can be dev-mode-only too.
71
72
  devMode?: boolean;
72
73
  // Settings-panel-only (ignored in ad hoc forms) — see settings.md's
73
- // "Common Settings and environment gating". Also shows this field on
74
- // the app's Common Settings quick-access panel.
74
+ // "Common Preferences and environment gating". Also shows this field on
75
+ // the app's Common Preferences quick-access panel.
75
76
  common?: boolean;
76
77
  // Settings-panel-only. Restricts which platform(s) show this field at
77
78
  // all: one or more platform tokens (`ios`, `android`, `macos`,
@@ -129,6 +130,8 @@ export interface FormMarkdownBlock {
129
130
  // SDK already permits (see settings.md's settingsPanel note). Not used for
130
131
  // account credentials — those have their own testCredentials on AccountHook.
131
132
  export interface FormActionElement {
133
+ style?: 'row'; // icon row with a chevron; omit for a button
134
+ icon?: string;
132
135
  type: 'action';
133
136
  key: string;
134
137
  label: string;
@@ -277,3 +280,28 @@ api.registerHook('form', {
277
280
  - Any validation error returns back to Dart and is displayed underneath the field. If any fields have errors, submission is aborted.
278
281
  3. **Submit transformation**: When all fields are successfully validated, Dart invokes any JS transformation callbacks for each field.
279
282
  4. **Completion**: The final transformed state is resolved and returned back to the caller (or saved in the view context).
283
+
284
+ ## Text templates
285
+
286
+ `fieldType: 'textTemplate'` is a string field displayed as an editor row in
287
+ forms and settings. It opens a multiline Liquid editor with a sample preview,
288
+ syntax errors, insertable attributes and flow-control examples, and a read-only
289
+ filter reference inside collapsed Templating Docs. Editing and preview stay
290
+ above the scrollable reference. Template rows also display rendered samples. Save commits
291
+ the draft; Cancel, Escape and dismissal discard it. `defaultValue` supplies
292
+ the Reset button. Disabled fields cannot open the editor.
293
+
294
+ ```ts
295
+ {
296
+ type: 'field', fieldType: 'textTemplate', key: 'filename',
297
+ label: 'File name', value: '{{ op.date }} {{ log.station }}',
298
+ templateContext: 'export', // 'adif' adds contact attributes; 'text' is generic
299
+ templateSample: { log: { activity: 'POTA', ref: 'US-1234' } },
300
+ }
301
+ ```
302
+
303
+ The preview uses the same runtime and vocabulary as exports; sample values
304
+ are illustrative. `templateSample` can replace values in `operation`, `qso`
305
+ and `log`. The app provides the rendering callback through
306
+ `TemplateEditorScope`, keeping the form widget independent of extensions.
307
+ See [templates.md](templates.md) for Liquid syntax and supported namespaces.
package/docs/hooks.md CHANGED
@@ -248,23 +248,91 @@ fetch-only for now.
248
248
 
249
249
  ```ts
250
250
  interface ExportHook {
251
+ getExportTypes?(args: {}, ctx): Promise<ExportTypeDefinition[]>
251
252
  suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
252
253
  generateExport(args: ExportRequest, ctx): Promise<ExportResult>
253
254
  }
254
255
  // ExportOptionsRequest: {operation, qsos, compactFilenames?}
255
- // ExportOption: {exportType, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
256
- // ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
256
+ // ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
257
+ // ExportRequest: {operation, qsos, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
257
258
  // ExportResult: {filename, mimeType, content}
258
259
  ```
259
260
 
260
261
  Two-step flow: the Exports Panel calls `suggestExportOptions` on every
261
262
  `export` hook to build its selectable list (a hook that omits it never
262
263
  appears in the panel), then calls `generateExport` — passing back the
263
- chosen option's `exportType` — only for the options the user selected. The
264
- core owns file I/O and save/share; the hook only produces content.
264
+ chosen option's `exportType` and `exportKey` — only for the options the user
265
+ selected. The core owns file I/O and save/share; the hook only produces content.
266
+
267
+ `exportType` is the stable kind (for example `potaActivation-adif`); `exportKey`
268
+ identifies an individual option within the hook (for example
269
+ `pota-adif:US-1234`). Options sharing a type must have distinct keys. Omit
270
+ `exportKey` for a single option of a type: selection and generation default
271
+ it to `exportType`. Both values round-trip unchanged when an explicit key is
272
+ provided; the core does not interpret them. Selection also includes the
273
+ hook key and station callsign, keeping different hooks and stations independent.
274
+
265
275
  Exporters should compose per-QSO program fields from `adifFields` hooks (see
266
- below) rather than knowing about specific activities. Implemented by: `adif`
267
- (key `adif`, one option); Cabrillo will register here too.
276
+ below) rather than knowing about specific activities. Implemented by the whole-log `adif` exporter, activity exports, and contest
277
+ ADIF/Cabrillo exporters.
278
+
279
+ **Registering types and settings.** `getExportTypes` runs without an operation,
280
+ so Settings can list types even before the operator has a matching log:
281
+
282
+ ```ts
283
+ async getExportTypes() {
284
+ return [{
285
+ exportType: 'potaActivation-adif',
286
+ activationType: 'potaActivation',
287
+ format: 'adif',
288
+ label: 'POTA',
289
+ defaults: { includePrivateData: false, includeLookupData: true },
290
+ }]
291
+ }
292
+ ```
293
+
294
+ Prefer `<activationType>-<format>` to an extension-key namespace. Two
295
+ extensions offering that type share one settings entry. The highest-priority
296
+ registration supplies its definition; other exporters using it must honor
297
+ that contract. Distinct sponsor requirements can use a distinct type. The
298
+ SDK's `exportTypeDefinition(activationType, format, label, defaults?)` builds
299
+ the conventional definition; the activity and hunting helpers register their
300
+ own types. An exporter without `getExportTypes` still works, but does not
301
+ appear in the export settings list.
302
+
303
+ `ExportSettings` contains `includePrivateData`, `includeLookupData`,
304
+ `customTemplates`, `filenameTemplate`, `compactFilenameTemplate`,
305
+ `titleTemplate`, `adifNotesTemplate`, `adifCommentTemplate`, and
306
+ `adifQslMessageTemplate`. All are optional. Defaults resolve from the SDK,
307
+ then global preferences, then the type's declared defaults.
308
+ `GlobalExportSettings` adds `referenceFilenameTemplate`,
309
+ `referenceCompactFilenameTemplate`, `referenceTitleTemplate` and the matching
310
+ `other…` fields. A type with `templateCategory: 'reference'` uses the reference
311
+ set; other types use the other set. The activity export helper registers
312
+ reference types automatically. ADIF field templates remain common to both sets.
313
+ Saved common filename/title defaults remain fallbacks for categories without
314
+ an explicit setting. Per-type data
315
+ choices override those; per-type template overrides apply only when
316
+ `customTemplates` is on. Empty templates intentionally suppress text. Turning
317
+ custom templates off preserves the operator's edits for later. Non-ADIF
318
+ formats expose only the filename templates and their custom-template switch.
319
+
320
+ Type definitions may supply `templateSample: {operation, log}` for representative
321
+ editor examples. Each format supplies its own filename extension in the sample.
322
+
323
+ An option can supply `qsoCount` when its hook further filters the contacts
324
+ (for example, a hunter export). Otherwise the host derives the count from
325
+ activity and station scope. Filename and title templates use this count.
326
+
327
+ Options can carry `templateData: {ref, refName, activity, modifier, ...}`,
328
+ which populates the `log` namespace. The app renders filenames and titles
329
+ before showing the option, and passes the same `exportSettings`, `exportData`
330
+ and `exportTitle` to generation. It also passes the resolved
331
+ `includePrivateData` and `includeLookupData` for ADIF. Forward those fields
332
+ when delegating through `adifForExport`, or a program's preferences will not
333
+ reach the ADIF writer. Settings apply globally and per type, never per operation.
334
+ The whole-log ADIF defaults to including private data, but its type setting
335
+ can override that default.
268
336
 
269
337
  **How a row looks.** `refType` names the activity the export covers — the
270
338
  core slices the file's QSOs by it, and the panel takes the row's icon and
@@ -1169,6 +1237,7 @@ own identity, config form and refresh budget.
1169
1237
  interface PanelHook {
1170
1238
  getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
1171
1239
  render(args: PanelRenderArgs, ctx): Promise<PanelContent>
1240
+ onEvent?(args: PanelRenderArgs & {event: PanelSceneEvent}, ctx): Promise<PanelSceneEventResult>
1172
1241
  }
1173
1242
  // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1174
1243
  // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
@@ -1179,6 +1248,101 @@ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1179
1248
  inside saved layouts — renaming a `key` drops the panel out of every
1180
1249
  arrangement holding it.
1181
1250
 
1251
+ An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
1252
+ local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
1253
+ title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
1254
+ `instanceId` on render/event calls. `onEvent` handles control events and returns
1255
+ numeric value patches; local-only controls and animation frames make no bridge
1256
+ calls. Controls with `continuous: true` send `change` events during dragging and a
1257
+ final `commit` on release; pending movements are coalesced to the newest value.
1258
+ Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
1259
+ for local numeric formatting. The Radio Panel Example uses these for its Modern
1260
+ frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
1261
+ may hold numbers or preformatted strings; control `valueLabels` supplies accessible
1262
+ value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1263
+ equally spaced over their `input` range, instead of the `output` ramp. The host
1264
+ interpolates and clamps these locally; chart markers and callouts can follow
1265
+ individual readings without bridge calls. See [the prototype contract and migration plan](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
1266
+ and the `k2hrc-svg-scenes` sample. This API is experimental.
1267
+
1268
+ Scene-capable hosts also supply `args.environment` on **render and event** calls:
1269
+
1270
+ - `width`, `height`, and `safeInsets` describe the current panel in logical pixels.
1271
+ The app's Font Scale is already applied to this coordinate space. Do not multiply
1272
+ coordinates by a font-scale preference or `devicePixelRatio`.
1273
+ - `brightness` is resolved to `light` or `dark`, including system theme changes.
1274
+ `colors` contains `surface`, `surfaceContainer`, `onSurface`, `onSurfaceVariant`,
1275
+ `accent`, `primary`, `onPrimary`, `secondary`, `outline`, `outlineVariant`,
1276
+ `error`, and `onError`, all `#RRGGBB` strings.
1277
+ - `typography` contains `body`, `label`, `title`, `display`, and `mono` roles, each
1278
+ with `fontFamily`, fallbacks, `fontSize`, `scaledFontSize`, `fontWeight`,
1279
+ `lineHeight`, and `letterSpacing`. Family can be null for a platform default.
1280
+ `scaledFontSize` describes OS accessibility scaling **at that role's size**;
1281
+ do not assume one linear factor for all sizes. Use it when reserving label space.
1282
+ Supply the unscaled `fontSize` as scene text `size`: the host scales text once.
1283
+ - `locale`, `textDirection`, `devicePixelRatio`, `reducedMotion`, and `highContrast`
1284
+ describe the resolved presentation context. Font names refer to host fonts;
1285
+ they are not URLs or permission to load remote fonts.
1286
+
1287
+ For a panel showing an `svgScene`, environment changes automatically request a
1288
+ render through the existing throttle, even with no triggers declared. Hidden
1289
+ panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
1290
+ an old environment cannot overwrite a newer one. Markdown and HTML panels are
1291
+ themed and laid out by the host, so an environment change does not re-render
1292
+ them; their next render carries the current environment.
1293
+ `args.clock.nowMillis` follows the app clock; `realNowMillis` is for network retry
1294
+ and cache budgets, so developer time travel does not hammer an external API.
1295
+ Both `environment` and `clock` are optional in the SDK for older-host detection.
1296
+
1297
+ Use scene **text layers** for typography; SVG `<text>` is not a portable text path
1298
+ through the renderer. Text supports either `literal` or bound `value`, plus
1299
+ `fontFamily`, `fontWeight`, `lineHeight`, and `letterSpacing`. Text participates
1300
+ in native directionality, accessibility scaling and semantics.
1301
+ Button controls can supply `menu: [{label, event}]` to open a native dropdown.
1302
+ Selecting an item emits its `event` as the action with the original control ID;
1303
+ dismissing the menu emits nothing.
1304
+
1305
+ The `svg-weather`, `svg-solar`, and `svg-radio` reference panels live in
1306
+ [ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
1307
+ They use public host APIs and ship as `.h2kext` packages, not app assets.
1308
+ Install the packages, then add their panels through Edit Layout. Their original
1309
+ extension and panel keys are retained so existing placements and settings survive.
1310
+ See that repository’s dashboard README for building against the unreleased SDK.
1311
+
1312
+ The radio host calls are manifest capabilities, refused unless declared — the
1313
+ same shape as `requiresLocation`, and shown to the operator at install:
1314
+ `"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
1315
+ `"requiresRadioWrite": true` allows `host.tuneRadio()` and
1316
+ `host.setRadioConnection()`, and implies read, since a command answers with the
1317
+ radio's state. An undeclared call rejects rather than answering null.
1318
+
1319
+ **Radio Panel Example** is the CAT reference extension. `host.listRadios()` lists configured
1320
+ local transceivers. `host.readRadio(id?)` returns
1321
+ a **local** radio's reported frequency (Hz), mode, connection state,
1322
+ power, TX state and fresh telemetry, or null on hosts without this API.
1323
+ `meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
1324
+ times a second; it expires after two seconds without a reading. The Flex driver receives the selected slice’s LEVEL
1325
+ meter through its native UDP channel. The reference panel draws a segmented
1326
+ receive meter and leaves it unlit when unavailable, stale, or transmitting. It does
1327
+ not substitute the app's manually entered VFO for a radio reading.
1328
+ `host.tuneRadio({id, frequencyHz?, mode?})` targets that same radio through the
1329
+ existing VFO/CAT service. Supported mode requests are LSB, USB, CW, AM and FM.
1330
+ The host rejects a changed designation, disconnected/stale radio, transmission
1331
+ in progress or malformed command, and spaces accepted tuning requests by at
1332
+ least 250 ms. Omitting a selection follows the designated logging radio; an explicit `selection`
1333
+ pins tuning to that local radio without changing the logging designation.
1334
+ An accepted result means requested, not confirmed; only subsequent
1335
+ reported state confirms it. Drivers retain their own protocol/echo handling.
1336
+ `host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
1337
+ connection using the same selection and identity checks. Acceptance starts the
1338
+ operation; follow reported connection state for completion. It does not switch
1339
+ the hardware's mains power or initiate transmission. The reference panel samples
1340
+ state once per second while visible; knob motion and LCD digit updates run locally,
1341
+ and tuning is sent continuously during dragging, paced by the CAT adapter. The
1342
+ main readout follows the requested value immediately; confirmation status distinguishes pending requests from the radio
1343
+ reporting the requested frequency. Radio-specific hardware verification remains
1344
+ necessary before considering the experimental control API stable.
1345
+
1182
1346
  **Content is a document, not widgets** — the same UI-catalog seam every
1183
1347
  other declarative hook sits behind. Prefer `markdown`: it renders with the
1184
1348
  app's own typography, theme, font scale and density, and costs no web view.
package/docs/settings.md CHANGED
@@ -372,17 +372,18 @@ Aliases are for names an operator would plausibly *reach for* — not a thesauru
372
372
  Every alias is one more thing that can collide with another setting's real name,
373
373
  and a collision costs a disambiguation prompt on a query that used to be exact.
374
374
 
375
- ## Common Settings and environment gating
375
+ ## Common Preferences and environment gating
376
376
 
377
- The app has one settings screen, **Settings**: a collapsible section per
378
- declared group and panel, led by **Common Settings**, a short list of the
377
+ The app has one settings screen, **Settings**, whose **Application
378
+ Preferences** panel has a collapsible section per declared group and panel,
379
+ led by **Common Preferences**, a short list of the
379
380
  handful of settings most users ever touch. Which fields land on Common
380
- Settings —
381
+ Preferences —
381
382
  and which platforms show a field at all — is declared per-element, not
382
383
  maintained as a separate list somewhere else in the app. Any field, link, or
383
384
  action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
384
385
 
385
- - **`common: true`** — also show this element in the Common Settings
386
+ - **`common: true`** — also show this element in the Common Preferences
386
387
  section, on top of its own group's. Off by default. Not the same thing as
387
388
  `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
388
389
  option's own disclosure tier (upfront vs. behind "Show more"), a
@@ -395,7 +396,7 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
395
396
  - **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
396
397
  restrict which platform(s) show this element at all. This is a platform
397
398
  gate, not a common-vs-everything one: an excluded field disappears from every
398
- settings surface on that platform, not just Common Settings. Every
399
+ settings surface on that platform, not just Common Preferences. Every
399
400
  token unprefixed is a whitelist (show ONLY there); every token
400
401
  `-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
401
402
  forms in one attribute is invalid and treated as if `environment` were
package/docs/templates.md CHANGED
@@ -19,7 +19,7 @@ const text = renderTemplate("{{ op.station }} — {{ op.qsoCount }} QSOs", templ
19
19
 
20
20
  A template is DATA. Liquid parses and interprets; Handlebars compiles
21
21
  through `new Function`. These templates are written by operators, copied
22
- between operators, and will eventually be editable in settings, so the one
22
+ between operators, and are editable in settings, so the one
23
23
  that cannot become code is the one to have. Liquid also has comparisons
24
24
  (`{% if op.qsoCount > 100 %}`) and loops built in, where Handlebars needs a
25
25
  registered helper for `>` and Mustache cannot compare at all.
@@ -42,15 +42,15 @@ the namespaces are deliberately polo's. The syntax around them does not:
42
42
  |---|---|---|---|---|
43
43
  | `app` | ✓ | — | ✓ | ✓ |
44
44
  | `now` | ✓ | ✓ | ✓ | ✓ |
45
- | `op` | ✓ | dates only | ✓ | ✓ |
45
+ | `op` | ✓ | ✓ | ✓ | ✓ |
46
46
  | `qso` | when the placement's triggers ask for it | — | ✓ | the draft contact, as far as it is typed |
47
47
  | `config` | ✓ | — | — | — |
48
48
  | `log` | — | ✓ | ✓ | — |
49
49
 
50
50
  A namespace a surface has nothing for is **absent**, not blank — which is
51
- what makes `{% if qso %}` an honest question. Filenames get only the date
52
- half of `op` because a filename is built from parts rather than from an
53
- operation (`exportNames.ts`).
51
+ what makes `{% if qso %}` an honest question. Registered exports get the operation plus the selected file’s `log` values.
52
+ The low-level `exportFilename(parts)` helper still supplies only dates and
53
+ filename parts.
54
54
 
55
55
  ### `app`
56
56
  `app.name` — the platform-appropriate name ("Ham2K Logger" on desktop,
@@ -191,16 +191,16 @@ will jump rather than count. Show HH:MM.
191
191
  | what | where | editable |
192
192
  |---|---|---|
193
193
  | Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
194
- | Export filenames | `sdk/src/exportNames.ts` `NAME_TEMPLATES` | not yet |
195
- | ADIF NOTES / COMMENT / QSLMSG | `core/adif`'s `TEXT_FIELD_TEMPLATES` | not yet |
194
+ | Export filenames and titles | Registered export types and `sdk/src/exportSettings.ts` | Settings → Exports, globally and per type |
195
+ | ADIF NOTES / COMMENT / QSLMSG | Export type settings, consumed by `core/adif` | Settings → Exports; empty templates suppress a field |
196
196
  | CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's Messages… dialog |
197
197
 
198
- COMMENT and QSLMSG are empty, so nothing is written for them. app-polo
199
- defaults COMMENT to the QSO's notes and QSLMSG to the operation's
200
- references; both wait here until there is a settings screen to edit them in,
201
- since a default nobody can see is one nobody can turn off, and these fields
202
- travel to a program's servers.
198
+ NOTES and COMMENT default to QSO notes and are withheld when private data
199
+ is off. QSLMSG defaults to empty for program exports, and to the operation’s
200
+ references for the whole-log ADIF. Custom QSL messages may use contact values.
201
+ Templates receive no withheld private or lookup values. File titles obey the
202
+ private-data choice; filename templates are labels for the operator’s files.
203
203
 
204
- Whatever fills them must respect the private/public split: `notes` is the
205
- operator's own words and belongs only in a field the export withholds when
206
- private data is off. NOTES is such a field. COMMENT and QSLMSG are not.
204
+ The `textTemplate` form field provides sample previews and an insertable
205
+ reference; see [forms.md](forms.md). Invalid Liquid reports an error rather
206
+ than silently replacing the operator’s template with a default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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
@@ -10,6 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
10
10
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
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
+ | `k2hrc-svg-scenes` | Experimental SVG controls and animations, with simulated radio and weather panels |
13
14
 
14
15
  ## Running one
15
16
 
@@ -0,0 +1,24 @@
1
+ # SVG Scene Prototypes
2
+
3
+ Experimental radio front face and weather inspection chart, using the public
4
+ `svgScene` / `PanelHook.onEvent` contracts. Requires a host with SVG scene support.
5
+ All data is simulated; this extension does not fetch weather or control a radio.
6
+
7
+ The radio keeps requested frequency separate from reported frequency, simulates
8
+ 2-second CAT acknowledgments, and has buttons for external tuning, disconnection,
9
+ and changing acknowledgment delay. Drag the knob horizontally/upward, use the band
10
+ slider, or focus either control and use arrows (Shift for larger steps) / the wheel.
11
+
12
+ The weather chart demonstrates local-only cursor and numeric sample inspection.
13
+ Drag, click, or use arrows; no interaction events cross the extension bridge.
14
+
15
+ In this repository:
16
+
17
+ ```sh
18
+ node build.mjs
19
+ node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-svg-scenes.h2kext
20
+ ```
21
+
22
+ For the standalone Flutter preview and migration plan, see
23
+ `docs/design/svg-scenes.md` in the logger repository. This is an API experiment,
24
+ not a replacement for the production weather/solar panels yet.
@@ -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,31 @@
1
+ {
2
+ "key": "k2hrc-svg-scenes",
3
+ "name": "SVG Scene Prototypes",
4
+ "shortName": "SVG Scenes",
5
+ "version": "0.1.0",
6
+ "description": "Experimental radio front face and weather chart; simulated data only",
7
+ "category": "dashboard",
8
+ "icon": "radio-tower",
9
+ "accentColor": "#4C6EF5",
10
+ "api": 1,
11
+ "keywords": [
12
+ "svg",
13
+ "panel",
14
+ "sample",
15
+ "weather",
16
+ "radio"
17
+ ],
18
+ "hooks": [
19
+ "panel"
20
+ ],
21
+ "sharedDependencies": {
22
+ "@ham2k/lib-callsigns": "^1.0.0",
23
+ "@ham2k/lib-country-files": "^1.0.0",
24
+ "@ham2k/lib-dxcc-data": "^1.0.0",
25
+ "@ham2k/lib-format-tools": "^1.0.0",
26
+ "@ham2k/lib-geo-tools": "^1.0.0",
27
+ "@ham2k/lib-operation-data": "^1.0.0",
28
+ "i18next": "^23.0.0",
29
+ "liquidjs": "^10.0.0"
30
+ }
31
+ }
@@ -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 { createScenePanels } from './scenes.ts'
6
+
7
+ defineExtension({
8
+ ...manifest,
9
+ onActivation({ registerHook }) {
10
+ registerHook('panel', { key: manifest.key, hook: createScenePanels() })
11
+ },
12
+ })
@@ -0,0 +1,128 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
4
+
5
+ const svg = (body: string, width = 640, height = 360) =>
6
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
7
+ const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
8
+ const label = (x: number, y: number, text: string, size = 16, color = '#9aaec6') =>
9
+ `<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
10
+ const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, '#e7edf4')}`
11
+ const textLayer = (id: string, value: string, x: number, y: number, width: number,
12
+ size: number, prefix = '', suffix = '', decimals = 0): SvgSceneLayer =>
13
+ ({ id, x, y, width, height: size + 12, text: { value, prefix, suffix, decimals, size, color: '#a9efcf' } })
14
+
15
+ type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
16
+ /** Inject time for deterministic tests; no timers or per-frame extension work. */
17
+ export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
18
+ const radios = new Map<string, RadioState>()
19
+ function state(id: string): RadioState {
20
+ let s = radios.get(id)
21
+ if (!s) {
22
+ // Bounded session-only simulator state; no storage or CAT commands.
23
+ if (radios.size >= 64) radios.delete(radios.keys().next().value!)
24
+ s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
25
+ radios.set(id, s)
26
+ }
27
+ if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
28
+ return s
29
+ }
30
+ return {
31
+ async getPanels() {
32
+ return [
33
+ { key: 'radio', title: 'Radio Front Face · Simulation', icon: 'radio-tower', multiple: true, on: ['tick:1'] },
34
+ { key: 'weather', title: 'Weather Chart · Simulation', icon: 'weather-partly-cloudy', multiple: true },
35
+ ]
36
+ },
37
+ async render(args): Promise<PanelContent> {
38
+ if (args.panelKey === 'weather') {
39
+ const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
40
+ const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(' ')
41
+ return { kind: 'svgScene', scene: {
42
+ version: 1, width: 640, height: 360, values: { hour: 0 },
43
+ layers: [
44
+ { id: 'chart', x: 0, y: 0, width: 640, height: 360, svg: svg(`
45
+ <rect width="640" height="360" rx="18" fill="#142337"/>
46
+ ${label(30, 38, 'FORECAST / SIMULATED DATA', 18, '#e7edf4')}
47
+ ${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join('')}
48
+ <polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
49
+ ${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, '+' + i + 'h', 12)}`).join('')}
50
+ ${label(30, 334, 'Drag the chart to inspect · Arrow keys to step', 14)}
51
+ `) },
52
+ { id: 'cursor', x: 59, y: 100, width: 2, height: 172,
53
+ svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
54
+ translateX: bind('hour', [0, 7], [0, 518]) },
55
+ textLayer('hour', 'hour', 30, 52, 250, 24, 'In ', ' hours'),
56
+ { ...textLayer('temperature', 'hour', 370, 52, 240, 24, '', ' °C'),
57
+ text: { value: 'hour', samples: temperatures, suffix: ' °C', size: 24, color: '#a9efcf' } },
58
+ ],
59
+ controls: [{ id: 'inspect', kind: 'slider', label: 'Forecast hour', x: 60, y: 100, width: 518, height: 185,
60
+ value: 'hour', min: 0, max: 7, step: 1 }],
61
+ } }
62
+ }
63
+ const s = state(args.instanceId ?? args.panelKey)
64
+ return { kind: 'svgScene', scene: {
65
+ version: 1, width: 640, height: 360,
66
+ values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
67
+ layers: [
68
+ { id: 'face', x: 0, y: 0, width: 640, height: 360, svg: svg(`
69
+ <rect width="640" height="360" rx="18" fill="#142337"/>
70
+ ${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
71
+ <rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
72
+ ${label(42, 78, 'REPORTED VFO', 12)}${label(42, 143, 'REQUESTED', 12)}
73
+ <path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
74
+ ${label(423, 170, '14.0', 12)}${label(565, 170, '14.35', 12)}
75
+ ${label(424, 197, 'VFO POSITION', 12)}
76
+ <path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
77
+ ${label(42, 268, 'Band position / kHz', 14)}
78
+ ${label(548, 256, 'LINK', 12)}
79
+ ${button(28, 'Connect / disconnect')}${button(234, 'External tune +5 kHz')}${button(440, 'Toggle 2 s / 0 s delay')}
80
+ `) },
81
+ textLayer('reported', 'reported', 42, 78, 320, 34, '', ' kHz', 1),
82
+ textLayer('requested', 'requested', 168, 121, 210, 22, '', '', 1),
83
+ { id: 'needle', x: 503, y: 72, width: 8, height: 80, pivot: [0.5, 1], transitionMs: 450,
84
+ svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
85
+ rotation: bind('reported', [14000, 14350], [-80, 80]) },
86
+ { id: 'knob', x: 400, y: 204, width: 72, height: 72,
87
+ svg: svg('<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>', 72, 72),
88
+ rotation: bind('requested', [14000, 14350], [-150, 150]) },
89
+ { id: 'handle', x: 36, y: 224, width: 12, height: 24,
90
+ svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
91
+ translateX: bind('requested', [14000, 14350], [0, 290]) },
92
+ { id: 'led', x: 548, y: 208, width: 40, height: 40,
93
+ svg: svg('<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>', 40, 40),
94
+ opacity: bind('connected', [0, 1], [0.08, 1]), pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true } },
95
+ ],
96
+ controls: [
97
+ { id: 'tune', label: 'Tuning knob, kHz', kind: 'knob', x: 398, y: 202, width: 76, height: 76,
98
+ value: 'requested', min: 14000, max: 14350, step: 0.1, sensitivity: 0.1, event: 'tune' },
99
+ { id: 'band', label: 'Band position, kHz', kind: 'slider', x: 42, y: 215, width: 290, height: 42,
100
+ value: 'requested', min: 14000, max: 14350, step: 0.1, event: 'tune' },
101
+ ...(['connect', 'external', 'delay'] as const).map((event, i) => ({
102
+ id: event, label: ['Toggle connection', 'Simulate external tuning', 'Toggle CAT delay'][i],
103
+ kind: 'button' as const, x: 28 + i * 206, y: 298, width: 172, height: 38, event,
104
+ })),
105
+ ],
106
+ } }
107
+ },
108
+ async onEvent(args) {
109
+ const s = state(args.instanceId ?? args.panelKey)
110
+ const event = args.event
111
+ if (event.action === 'connect') { s.connected = 1 - s.connected; s.due = 0; s.requested = s.reported }
112
+ if (event.action === 'delay') s.slow = 1 - s.slow
113
+ if (event.action === 'external') {
114
+ if (!s.connected) throw new Error('Radio disconnected')
115
+ s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
116
+ s.requested = s.reported; s.due = 0
117
+ }
118
+ if (event.action === 'tune') {
119
+ if (!s.connected) throw new Error('Radio disconnected')
120
+ if (typeof event.value !== 'number' || !Number.isFinite(event.value)) throw new Error('Invalid frequency')
121
+ s.requested = Math.max(14000, Math.min(14350, event.value))
122
+ s.due = now() + (s.slow ? 2000 : 0)
123
+ if (!s.slow) { s.reported = s.requested; s.due = 0 }
124
+ }
125
+ return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
126
+ },
127
+ }
128
+ }