@ham2k/extension-sdk 0.5.6 → 0.6.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.
@@ -1,16 +1,20 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
2
  // SPDX-License-Identifier: MIT
3
- import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
3
+ import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
4
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>`
5
+ const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
7
6
  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') =>
7
+ const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
9
8
  `<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' } })
9
+ 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")}`
10
+ const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): SvgSceneLayer => ({
11
+ id,
12
+ x,
13
+ y,
14
+ width,
15
+ height: size + 12,
16
+ text: { value, prefix, suffix, decimals, size, color: "#a9efcf" },
17
+ })
14
18
 
15
19
  type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
16
20
  /** Inject time for deterministic tests; no timers or per-frame extension work. */
@@ -24,103 +28,214 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
24
28
  s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
25
29
  radios.set(id, s)
26
30
  }
27
- if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
31
+ if (s.connected && s.due && now() >= s.due) {
32
+ s.reported = s.requested
33
+ s.due = 0
34
+ }
28
35
  return s
29
36
  }
30
37
  return {
31
38
  async getPanels() {
32
39
  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 },
40
+ { key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
41
+ { key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
35
42
  ]
36
43
  },
37
44
  async render(args): Promise<PanelContent> {
38
- if (args.panelKey === 'weather') {
45
+ if (args.panelKey === "weather") {
39
46
  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(`
47
+ const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
48
+ return {
49
+ kind: "svgScene",
50
+ scene: {
51
+ version: 1,
52
+ width: 640,
53
+ height: 360,
54
+ values: { hour: 0 },
55
+ layers: [
56
+ {
57
+ id: "chart",
58
+ x: 0,
59
+ y: 0,
60
+ width: 640,
61
+ height: 360,
62
+ svg: svg(`
45
63
  <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('')}
64
+ ${label(30, 38, "FORECAST / SIMULATED DATA", 18, "#e7edf4")}
65
+ ${[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
66
  <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
- } }
67
+ ${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("")}
68
+ ${label(30, 334, "Drag the chart to inspect · Arrow keys to step", 14)}
69
+ `),
70
+ },
71
+ {
72
+ id: "cursor",
73
+ x: 59,
74
+ y: 100,
75
+ width: 2,
76
+ height: 172,
77
+ svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
78
+ translateX: bind("hour", [0, 7], [0, 518]),
79
+ },
80
+ textLayer("hour", "hour", 30, 52, 250, 24, "In ", " hours"),
81
+ {
82
+ ...textLayer("temperature", "hour", 370, 52, 240, 24, "", " °C"),
83
+ text: { value: "hour", samples: temperatures, suffix: " °C", size: 24, color: "#a9efcf" },
84
+ },
85
+ ],
86
+ controls: [
87
+ { id: "inspect", kind: "slider", label: "Forecast hour", x: 60, y: 100, width: 518, height: 185, value: "hour", min: 0, max: 7, step: 1 },
88
+ ],
89
+ },
90
+ }
62
91
  }
63
92
  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(`
93
+ return {
94
+ kind: "svgScene",
95
+ scene: {
96
+ version: 1,
97
+ width: 640,
98
+ height: 360,
99
+ values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
100
+ layers: [
101
+ {
102
+ id: "face",
103
+ x: 0,
104
+ y: 0,
105
+ width: 640,
106
+ height: 360,
107
+ svg: svg(`
69
108
  <rect width="640" height="360" rx="18" fill="#142337"/>
70
- ${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
109
+ ${label(28, 35, "RADIO FRONT FACE / SIMULATED CAT", 18, "#e7edf4")}
71
110
  <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)}
111
+ ${label(42, 78, "REPORTED VFO", 12)}${label(42, 143, "REQUESTED", 12)}
73
112
  <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)}
113
+ ${label(423, 170, "14.0", 12)}${label(565, 170, "14.35", 12)}
114
+ ${label(424, 197, "VFO POSITION", 12)}
76
115
  <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
- } }
116
+ ${label(42, 268, "Band position / kHz", 14)}
117
+ ${label(548, 256, "LINK", 12)}
118
+ ${button(28, "Connect / disconnect")}${button(234, "External tune +5 kHz")}${button(440, "Toggle 2 s / 0 s delay")}
119
+ `),
120
+ },
121
+ textLayer("reported", "reported", 42, 78, 320, 34, "", " kHz", 1),
122
+ textLayer("requested", "requested", 168, 121, 210, 22, "", "", 1),
123
+ {
124
+ id: "needle",
125
+ x: 503,
126
+ y: 72,
127
+ width: 8,
128
+ height: 80,
129
+ pivot: [0.5, 1],
130
+ transitionMs: 450,
131
+ svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
132
+ rotation: bind("reported", [14000, 14350], [-80, 80]),
133
+ },
134
+ {
135
+ id: "knob",
136
+ x: 400,
137
+ y: 204,
138
+ width: 72,
139
+ height: 72,
140
+ svg: svg(
141
+ '<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>',
142
+ 72,
143
+ 72,
144
+ ),
145
+ rotation: bind("requested", [14000, 14350], [-150, 150]),
146
+ },
147
+ {
148
+ id: "handle",
149
+ x: 36,
150
+ y: 224,
151
+ width: 12,
152
+ height: 24,
153
+ svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
154
+ translateX: bind("requested", [14000, 14350], [0, 290]),
155
+ },
156
+ {
157
+ id: "led",
158
+ x: 548,
159
+ y: 208,
160
+ width: 40,
161
+ height: 40,
162
+ svg: svg(
163
+ '<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)"/>',
164
+ 40,
165
+ 40,
166
+ ),
167
+ opacity: bind("connected", [0, 1], [0.08, 1]),
168
+ pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true },
169
+ },
170
+ ],
171
+ controls: [
172
+ {
173
+ id: "tune",
174
+ label: "Tuning knob, kHz",
175
+ kind: "knob",
176
+ x: 398,
177
+ y: 202,
178
+ width: 76,
179
+ height: 76,
180
+ value: "requested",
181
+ min: 14000,
182
+ max: 14350,
183
+ step: 0.1,
184
+ sensitivity: 0.1,
185
+ event: "tune",
186
+ },
187
+ {
188
+ id: "band",
189
+ label: "Band position, kHz",
190
+ kind: "slider",
191
+ x: 42,
192
+ y: 215,
193
+ width: 290,
194
+ height: 42,
195
+ value: "requested",
196
+ min: 14000,
197
+ max: 14350,
198
+ step: 0.1,
199
+ event: "tune",
200
+ },
201
+ ...(["connect", "external", "delay"] as const).map((event, i) => ({
202
+ id: event,
203
+ label: ["Toggle connection", "Simulate external tuning", "Toggle CAT delay"][i],
204
+ kind: "button" as const,
205
+ x: 28 + i * 206,
206
+ y: 298,
207
+ width: 172,
208
+ height: 38,
209
+ event,
210
+ })),
211
+ ],
212
+ },
213
+ }
107
214
  },
108
215
  async onEvent(args) {
109
216
  const s = state(args.instanceId ?? args.panelKey)
110
217
  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')
218
+ if (event.action === "connect") {
219
+ s.connected = 1 - s.connected
220
+ s.due = 0
221
+ s.requested = s.reported
222
+ }
223
+ if (event.action === "delay") s.slow = 1 - s.slow
224
+ if (event.action === "external") {
225
+ if (!s.connected) throw new Error("Radio disconnected")
115
226
  s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
116
- s.requested = s.reported; s.due = 0
227
+ s.requested = s.reported
228
+ s.due = 0
117
229
  }
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')
230
+ if (event.action === "tune") {
231
+ if (!s.connected) throw new Error("Radio disconnected")
232
+ if (typeof event.value !== "number" || !Number.isFinite(event.value)) throw new Error("Invalid frequency")
121
233
  s.requested = Math.max(14000, Math.min(14350, event.value))
122
234
  s.due = now() + (s.slow ? 2000 : 0)
123
- if (!s.slow) { s.reported = s.requested; s.due = 0 }
235
+ if (!s.slow) {
236
+ s.reported = s.requested
237
+ s.due = 0
238
+ }
124
239
  }
125
240
  return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
126
241
  },