@tiuxo/opencode-openrgb 0.0.0-stage → 0.1.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/CHANGELOG.md +43 -0
- package/LICENSE +11 -0
- package/README.md +287 -2
- package/index.ts +9 -0
- package/package.json +54 -4
- package/src/client.ts +464 -0
- package/src/controller.ts +360 -0
- package/src/index.ts +93 -0
- package/src/options.ts +229 -0
- package/src/palette.ts +86 -0
- package/src/protocol.ts +611 -0
- package/src/state.ts +215 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lighting controller: owns the OpenRGB connection, the aggregated session
|
|
3
|
+
* state, and everything that decides what the hardware should show.
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from `index.ts` so the logic stays directly importable and
|
|
6
|
+
* testable without going through the plugin entry point.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { OpenRGBClient, describe, findDirectMode, type Logger } from "./client.js"
|
|
10
|
+
import { formatColor, scaleColor } from "./palette.js"
|
|
11
|
+
import type { Device, Mode } from "./protocol.js"
|
|
12
|
+
import { selectDevices, type ResolvedOptions, type StateColor } from "./options.js"
|
|
13
|
+
import { StateTracker, type LightingState, type OpencodeEvent } from "./state.js"
|
|
14
|
+
|
|
15
|
+
const LOG_PREFIX = "[opencode-openrgb]"
|
|
16
|
+
|
|
17
|
+
/** Breathing effect bounds, as a fraction of the state's base brightness. */
|
|
18
|
+
const PULSE_MIN = 0.25
|
|
19
|
+
const PULSE_MAX = 1
|
|
20
|
+
const PULSE_PERIOD_MS = 2_400
|
|
21
|
+
|
|
22
|
+
interface Snapshot {
|
|
23
|
+
activeMode: number
|
|
24
|
+
colors: number[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Controller {
|
|
28
|
+
private readonly client: OpenRGBClient
|
|
29
|
+
private readonly tracker: StateTracker
|
|
30
|
+
private readonly log: Logger
|
|
31
|
+
|
|
32
|
+
private targets: Device[] = []
|
|
33
|
+
private readonly snapshots = new Map<number, Snapshot>()
|
|
34
|
+
/** Devices whose active mode we changed, and so must change back. */
|
|
35
|
+
private readonly modeSwitched = new Set<number>()
|
|
36
|
+
|
|
37
|
+
private applied: LightingState | null = null
|
|
38
|
+
private debounceTimer: NodeJS.Timeout | null = null
|
|
39
|
+
private pulseTimer: NodeJS.Timeout | null = null
|
|
40
|
+
private pulseStartedAt = 0
|
|
41
|
+
private disposed = false
|
|
42
|
+
|
|
43
|
+
constructor(private readonly options: ResolvedOptions) {
|
|
44
|
+
this.log = makeLogger(options.debug)
|
|
45
|
+
|
|
46
|
+
this.tracker = new StateTracker({
|
|
47
|
+
onExpire: () => this.schedule(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
this.client = new OpenRGBClient({
|
|
51
|
+
host: options.host,
|
|
52
|
+
port: options.port,
|
|
53
|
+
clientName: "opencode",
|
|
54
|
+
logger: this.log,
|
|
55
|
+
onDevicesReloaded: (devices) => this.onDevicesReloaded(devices),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
start(): void {
|
|
60
|
+
void this.client.ensureReady().then((ok) => {
|
|
61
|
+
if (!ok) {
|
|
62
|
+
this.log.debug("OpenRGB not reachable yet; will keep retrying in the background")
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handleEvent(event: OpencodeEvent): void {
|
|
68
|
+
if (this.disposed) return
|
|
69
|
+
try {
|
|
70
|
+
if (this.tracker.handle(event)) this.schedule()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.log.warn(`event handling failed: ${describe(error)}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*-------------------------------------------------------------------------*\
|
|
77
|
+
| Device targeting and snapshots |
|
|
78
|
+
\*-------------------------------------------------------------------------*/
|
|
79
|
+
|
|
80
|
+
private onDevicesReloaded(devices: readonly Device[]): void {
|
|
81
|
+
this.targets = selectDevices(devices, this.options)
|
|
82
|
+
this.snapshots.clear()
|
|
83
|
+
this.modeSwitched.clear()
|
|
84
|
+
|
|
85
|
+
for (const device of this.targets) {
|
|
86
|
+
this.snapshots.set(device.index, {
|
|
87
|
+
activeMode: device.activeMode,
|
|
88
|
+
colors: [...device.colors],
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.targets.length === 0) {
|
|
93
|
+
this.log.warn(
|
|
94
|
+
`no OpenRGB devices matched. Available: ${
|
|
95
|
+
devices.map((d) => `${d.name} (${d.typeName})`).join(", ") || "none"
|
|
96
|
+
}`,
|
|
97
|
+
)
|
|
98
|
+
} else {
|
|
99
|
+
this.log.debug(`targeting ${this.targets.map((d) => d.name).join(", ")}`)
|
|
100
|
+
this.warnIfSnapshotLooksStale()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The connection is new, so whatever we previously applied is meaningless.
|
|
104
|
+
this.applied = null
|
|
105
|
+
this.schedule(0)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* If the lighting we just captured is uniformly one of our own state colors,
|
|
110
|
+
* a previous run almost certainly failed to restore and we have snapshotted
|
|
111
|
+
* its leftovers as the "baseline". Say so, rather than silently treating a
|
|
112
|
+
* status color as the user's lighting.
|
|
113
|
+
*/
|
|
114
|
+
private warnIfSnapshotLooksStale(): void {
|
|
115
|
+
const palette = new Map<number, string>()
|
|
116
|
+
for (const [state, spec] of Object.entries(this.options.colors)) {
|
|
117
|
+
// Only fixed colors are a tell: a baseline state paints the snapshot
|
|
118
|
+
// back, so matching it proves nothing.
|
|
119
|
+
if (spec.kind === "fixed") palette.set(spec.color, state)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const device of this.targets) {
|
|
123
|
+
const colors = this.snapshots.get(device.index)?.colors ?? []
|
|
124
|
+
if (colors.length === 0) continue
|
|
125
|
+
const first = colors[0]!
|
|
126
|
+
if (!colors.every((c) => c === first)) continue
|
|
127
|
+
|
|
128
|
+
const state = palette.get(first)
|
|
129
|
+
if (state) {
|
|
130
|
+
this.log.warn(
|
|
131
|
+
`"${device.name}" was already showing the ${state} color (${formatColor(first)}) ` +
|
|
132
|
+
`at startup, so that is now the restore baseline. If a previous run exited ` +
|
|
133
|
+
`without restoring, reload your OpenRGB profile and restart opencode.`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/*-------------------------------------------------------------------------*\
|
|
140
|
+
| Scheduling |
|
|
141
|
+
\*-------------------------------------------------------------------------*/
|
|
142
|
+
|
|
143
|
+
private schedule(overrideDelay?: number): void {
|
|
144
|
+
if (this.disposed) return
|
|
145
|
+
|
|
146
|
+
const next = this.tracker.resolve()
|
|
147
|
+
const delay =
|
|
148
|
+
overrideDelay ??
|
|
149
|
+
(next === "idle"
|
|
150
|
+
? this.options.debounceMs + this.options.restoreDelayMs
|
|
151
|
+
: this.options.debounceMs)
|
|
152
|
+
|
|
153
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
154
|
+
this.debounceTimer = setTimeout(() => {
|
|
155
|
+
this.debounceTimer = null
|
|
156
|
+
void this.apply()
|
|
157
|
+
}, delay)
|
|
158
|
+
this.debounceTimer.unref?.()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async apply(): Promise<void> {
|
|
162
|
+
if (this.disposed) return
|
|
163
|
+
|
|
164
|
+
const state = this.tracker.resolve()
|
|
165
|
+
if (state === this.applied) return
|
|
166
|
+
|
|
167
|
+
const ready = await this.client.ensureReady()
|
|
168
|
+
if (!ready || this.disposed) return
|
|
169
|
+
if (this.targets.length === 0) return
|
|
170
|
+
|
|
171
|
+
// Re-resolve: awaiting the connection may have taken a while.
|
|
172
|
+
const current = this.tracker.resolve()
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (current === "idle" && this.options.idle.kind === "restore") {
|
|
176
|
+
this.stopPulse()
|
|
177
|
+
this.restore()
|
|
178
|
+
} else {
|
|
179
|
+
const spec: StateColor =
|
|
180
|
+
current === "idle"
|
|
181
|
+
? { kind: "fixed", color: (this.options.idle as { color: number }).color }
|
|
182
|
+
: this.options.colors[current]
|
|
183
|
+
|
|
184
|
+
if (this.shouldPulse(current, spec)) {
|
|
185
|
+
this.startPulse(spec)
|
|
186
|
+
} else {
|
|
187
|
+
this.stopPulse()
|
|
188
|
+
this.paint(spec, 1)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this.applied = current
|
|
192
|
+
this.log.debug(
|
|
193
|
+
`state -> ${current}${
|
|
194
|
+
current !== "idle" && this.options.colors[current]?.kind === "baseline"
|
|
195
|
+
? " (baseline)"
|
|
196
|
+
: ""
|
|
197
|
+
}`,
|
|
198
|
+
)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.log.warn(`failed to apply state ${current}: ${describe(error)}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/*-------------------------------------------------------------------------*\
|
|
205
|
+
| Painting |
|
|
206
|
+
\*-------------------------------------------------------------------------*/
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A state whose color is the baseline must pulse, otherwise it would render
|
|
210
|
+
* identically to idle and convey nothing. Otherwise, pulsing is opt-in and
|
|
211
|
+
* applies to the busy state only.
|
|
212
|
+
*/
|
|
213
|
+
private shouldPulse(state: LightingState, spec: StateColor): boolean {
|
|
214
|
+
if (spec.kind === "baseline") return true
|
|
215
|
+
return this.options.pulse && state === "busy"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private paint(spec: StateColor, factor: number): void {
|
|
219
|
+
for (const device of this.targets) {
|
|
220
|
+
const colors = this.colorsFor(device, spec, factor)
|
|
221
|
+
if (colors.length === 0) continue
|
|
222
|
+
this.ensureDirectMode(device)
|
|
223
|
+
this.client.updateLeds(device.index, colors)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the per-LED color array for a device.
|
|
229
|
+
*
|
|
230
|
+
* For `baseline` this scales each LED's *own* snapshot color, so a device
|
|
231
|
+
* whose profile uses several colors keeps that pattern and simply breathes.
|
|
232
|
+
*/
|
|
233
|
+
private colorsFor(device: Device, spec: StateColor, factor: number): number[] {
|
|
234
|
+
const count = device.colors.length || device.leds.length
|
|
235
|
+
if (count === 0) return []
|
|
236
|
+
|
|
237
|
+
const intensity = this.options.brightness * factor
|
|
238
|
+
|
|
239
|
+
if (spec.kind === "fixed") {
|
|
240
|
+
return new Array<number>(count).fill(scaleColor(spec.color, intensity))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const base = this.snapshots.get(device.index)?.colors ?? []
|
|
244
|
+
if (base.length === 0) return []
|
|
245
|
+
return Array.from({ length: count }, (_, i) =>
|
|
246
|
+
scaleColor(base[i] ?? base[base.length - 1]!, intensity),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Per-LED color only takes effect in a direct/custom mode, so switch the
|
|
252
|
+
* device into one. Most devices are already there (both reference devices
|
|
253
|
+
* report "Direct" as the active mode), in which case this is a no-op.
|
|
254
|
+
*/
|
|
255
|
+
private ensureDirectMode(device: Device): void {
|
|
256
|
+
const direct = findDirectMode(device)
|
|
257
|
+
if (!direct) {
|
|
258
|
+
this.client.setCustomMode(device.index)
|
|
259
|
+
this.modeSwitched.add(device.index)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (device.activeMode === direct.index) return
|
|
263
|
+
if (this.modeSwitched.has(device.index)) return
|
|
264
|
+
|
|
265
|
+
this.client.updateMode(device.index, direct.index, direct.mode)
|
|
266
|
+
this.modeSwitched.add(device.index)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param sync Use synchronous socket writes. Required when restoring from a
|
|
271
|
+
* process `exit` handler, where queued writes never reach the server.
|
|
272
|
+
*/
|
|
273
|
+
private restore(sync = false): void {
|
|
274
|
+
for (const device of this.targets) {
|
|
275
|
+
const snapshot = this.snapshots.get(device.index)
|
|
276
|
+
if (!snapshot) continue
|
|
277
|
+
|
|
278
|
+
if (this.modeSwitched.has(device.index)) {
|
|
279
|
+
const mode: Mode | undefined = device.modes[snapshot.activeMode]
|
|
280
|
+
if (mode) this.client.updateMode(device.index, snapshot.activeMode, mode, sync)
|
|
281
|
+
this.modeSwitched.delete(device.index)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (snapshot.colors.length > 0) {
|
|
285
|
+
this.client.updateLeds(device.index, snapshot.colors, sync)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/*-------------------------------------------------------------------------*\
|
|
291
|
+
| Pulse |
|
|
292
|
+
\*-------------------------------------------------------------------------*/
|
|
293
|
+
|
|
294
|
+
private startPulse(spec: StateColor): void {
|
|
295
|
+
this.stopPulse()
|
|
296
|
+
this.pulseStartedAt = Date.now()
|
|
297
|
+
|
|
298
|
+
const render = () => {
|
|
299
|
+
const elapsed = Date.now() - this.pulseStartedAt
|
|
300
|
+
const phase = ((elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS) * Math.PI * 2
|
|
301
|
+
// Cosine eased 0..1, mapped into the pulse band.
|
|
302
|
+
const wave = (1 - Math.cos(phase)) / 2
|
|
303
|
+
this.paint(spec, PULSE_MIN + (PULSE_MAX - PULSE_MIN) * wave)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Render the first frame immediately so the transition is not delayed by
|
|
307
|
+
// up to a full frame interval.
|
|
308
|
+
render()
|
|
309
|
+
|
|
310
|
+
const interval = Math.max(16, Math.round(1_000 / this.options.pulseFps))
|
|
311
|
+
this.pulseTimer = setInterval(() => {
|
|
312
|
+
if (this.disposed) return this.stopPulse()
|
|
313
|
+
render()
|
|
314
|
+
}, interval)
|
|
315
|
+
this.pulseTimer.unref?.()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private stopPulse(): void {
|
|
319
|
+
if (!this.pulseTimer) return
|
|
320
|
+
clearInterval(this.pulseTimer)
|
|
321
|
+
this.pulseTimer = null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/*-------------------------------------------------------------------------*\
|
|
325
|
+
| Shutdown |
|
|
326
|
+
\*-------------------------------------------------------------------------*/
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Best-effort restore on process teardown. Writes are small and go straight
|
|
330
|
+
* into the socket buffer, so this usually lands, but an abrupt kill can still
|
|
331
|
+
* leave the last color showing.
|
|
332
|
+
*/
|
|
333
|
+
shutdown(): void {
|
|
334
|
+
if (this.disposed) return
|
|
335
|
+
this.disposed = true
|
|
336
|
+
|
|
337
|
+
this.stopPulse()
|
|
338
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
// Synchronous writes: the event loop will not run again.
|
|
342
|
+
if (this.client.isReady && this.options.idle.kind === "restore") this.restore(true)
|
|
343
|
+
} catch {
|
|
344
|
+
// Nothing useful to do while the process is going away.
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.tracker.dispose()
|
|
348
|
+
this.client.dispose()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function makeLogger(debug: boolean): Logger {
|
|
353
|
+
return {
|
|
354
|
+
debug: debug
|
|
355
|
+
? (message: string) => console.error(`${LOG_PREFIX} ${message}`)
|
|
356
|
+
: () => {},
|
|
357
|
+
// Warnings are always surfaced: they indicate misconfiguration.
|
|
358
|
+
warn: (message: string) => console.error(`${LOG_PREFIX} ${message}`),
|
|
359
|
+
}
|
|
360
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-openrgb
|
|
3
|
+
*
|
|
4
|
+
* Ambient status lighting for opencode. Mirrors aggregate session state onto
|
|
5
|
+
* OpenRGB-controlled hardware and hands your original lighting back when
|
|
6
|
+
* everything goes idle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Plugin } from "@opencode/plugin"
|
|
10
|
+
|
|
11
|
+
import { Controller } from "./controller.js"
|
|
12
|
+
import { resolveOptions } from "./options.js"
|
|
13
|
+
import { SUBSCRIBED_EVENTS, type OpencodeEvent } from "./state.js"
|
|
14
|
+
|
|
15
|
+
export type { ResolvedOptions, IdleBehaviour, ActiveState, StateColor } from "./options.js"
|
|
16
|
+
export type { LightingState, OpencodeEvent } from "./state.js"
|
|
17
|
+
export type { Device, Mode, Zone, Led } from "./protocol.js"
|
|
18
|
+
|
|
19
|
+
const LOG_PREFIX = "[opencode-openrgb]"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Process-wide shared controller.
|
|
23
|
+
*
|
|
24
|
+
* A single opencode server loads this plugin once per location, but the RGB
|
|
25
|
+
* hardware is global to the machine, so several controllers would fight over
|
|
26
|
+
* it. Instances share one controller and refcount it: the first caller builds
|
|
27
|
+
* it, the last one to leave shuts it down. Shutting down on the first unload
|
|
28
|
+
* instead would darken the lights for every location still running.
|
|
29
|
+
*/
|
|
30
|
+
let shared: { controller: Controller; refs: number } | null = null
|
|
31
|
+
|
|
32
|
+
function acquire(options: unknown): Controller | null {
|
|
33
|
+
if (shared) {
|
|
34
|
+
shared.refs++
|
|
35
|
+
return shared.controller
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const resolved = resolveOptions(options, (message) =>
|
|
39
|
+
console.error(`${LOG_PREFIX} ${message}`),
|
|
40
|
+
)
|
|
41
|
+
if (!resolved.enabled) return null
|
|
42
|
+
|
|
43
|
+
const controller = new Controller(resolved)
|
|
44
|
+
shared = { controller, refs: 1 }
|
|
45
|
+
controller.start()
|
|
46
|
+
return controller
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function release(): void {
|
|
50
|
+
if (!shared) return
|
|
51
|
+
shared.refs--
|
|
52
|
+
if (shared.refs > 0) return
|
|
53
|
+
shared.controller.shutdown()
|
|
54
|
+
shared = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* Declared as a `Plugin` literal rather than via `Plugin.define`, which is an
|
|
59
|
+
* identity function. That keeps `@opencode/plugin` a type-only import, so the
|
|
60
|
+
* published package has no runtime dependencies at all.
|
|
61
|
+
*/
|
|
62
|
+
const plugin: Plugin.Plugin = {
|
|
63
|
+
id: "opencode-openrgb",
|
|
64
|
+
setup(ctx) {
|
|
65
|
+
const controller = acquire(ctx.options)
|
|
66
|
+
if (!controller) return
|
|
67
|
+
|
|
68
|
+
const subscription = new AbortController()
|
|
69
|
+
|
|
70
|
+
void (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const events = ctx.event.subscribe({
|
|
73
|
+
types: [...SUBSCRIBED_EVENTS],
|
|
74
|
+
signal: subscription.signal,
|
|
75
|
+
})
|
|
76
|
+
for await (const event of events) {
|
|
77
|
+
controller.handleEvent(event as OpencodeEvent)
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// An abort during cleanup is the normal way this loop ends.
|
|
81
|
+
if (subscription.signal.aborted) return
|
|
82
|
+
console.error(`${LOG_PREFIX} event subscription failed: ${String(error)}`)
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
subscription.abort()
|
|
88
|
+
release()
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default plugin
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin option parsing.
|
|
3
|
+
*
|
|
4
|
+
* Options arrive from user config and are therefore untrusted. Every field is
|
|
5
|
+
* validated and falls back to a default on bad input, with a warning; a typo in
|
|
6
|
+
* `opencode.json` must never prevent the plugin from loading.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_HOST, DEFAULT_PORT, type Device } from "./protocol.js"
|
|
10
|
+
import { parseColor } from "./palette.js"
|
|
11
|
+
import type { LightingState } from "./state.js"
|
|
12
|
+
|
|
13
|
+
/** Lighting states that map to a solid color. `idle` is handled separately. */
|
|
14
|
+
export type ActiveState = Exclude<LightingState, "idle">
|
|
15
|
+
|
|
16
|
+
export type IdleBehaviour = { kind: "restore" } | { kind: "color"; color: number }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How a state's color is chosen.
|
|
20
|
+
*
|
|
21
|
+
* `baseline` means "whatever the lighting was when the plugin connected",
|
|
22
|
+
* per LED — so the device keeps its own color identity (your OpenRGB profile)
|
|
23
|
+
* and only its intensity changes. A baseline state always pulses, because a
|
|
24
|
+
* static baseline would be indistinguishable from idle.
|
|
25
|
+
*/
|
|
26
|
+
export type StateColor = { kind: "fixed"; color: number } | { kind: "baseline" }
|
|
27
|
+
|
|
28
|
+
/** Config values that select the baseline rather than a literal color. */
|
|
29
|
+
const BASELINE_ALIASES = new Set(["baseline", "current", "profile"])
|
|
30
|
+
|
|
31
|
+
export interface ResolvedOptions {
|
|
32
|
+
enabled: boolean
|
|
33
|
+
host: string
|
|
34
|
+
port: number
|
|
35
|
+
colors: Record<ActiveState, StateColor>
|
|
36
|
+
idle: IdleBehaviour
|
|
37
|
+
brightness: number
|
|
38
|
+
pulse: boolean
|
|
39
|
+
pulseFps: number
|
|
40
|
+
/** Debounce applied to all state transitions, to coalesce event bursts. */
|
|
41
|
+
debounceMs: number
|
|
42
|
+
/** Extra delay before handing lighting back on idle, to avoid thrashing. */
|
|
43
|
+
restoreDelayMs: number
|
|
44
|
+
include: string[]
|
|
45
|
+
exclude: string[]
|
|
46
|
+
debug: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_COLORS: Record<ActiveState, string> = {
|
|
50
|
+
busy: "#ff8c00",
|
|
51
|
+
retry: "#ffd000",
|
|
52
|
+
permission: "#b400ff",
|
|
53
|
+
error: "#ff0000",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Default targets: ambient/interior lighting only.
|
|
58
|
+
*
|
|
59
|
+
* This deliberately excludes `keyboard`, `mouse`, `keypad` and `accessory`.
|
|
60
|
+
* Those are functional devices whose lighting usually conveys something (an
|
|
61
|
+
* Elgato Stream Deck's button icons, for instance), and hijacking them by
|
|
62
|
+
* default would be obnoxious.
|
|
63
|
+
*/
|
|
64
|
+
const DEFAULT_INCLUDE = ["motherboard", "dram", "gpu", "cooler", "case", "ledstrip"]
|
|
65
|
+
|
|
66
|
+
const ACTIVE_STATES: ActiveState[] = ["busy", "retry", "permission", "error"]
|
|
67
|
+
|
|
68
|
+
export function resolveOptions(
|
|
69
|
+
raw: unknown,
|
|
70
|
+
warn: (message: string) => void = () => {},
|
|
71
|
+
): ResolvedOptions {
|
|
72
|
+
const o = isRecord(raw) ? raw : {}
|
|
73
|
+
|
|
74
|
+
const colors = {} as Record<ActiveState, StateColor>
|
|
75
|
+
const rawColors = isRecord(o["colors"]) ? o["colors"] : {}
|
|
76
|
+
for (const state of ACTIVE_STATES) {
|
|
77
|
+
const fallback: StateColor = { kind: "fixed", color: parseColor(DEFAULT_COLORS[state])! }
|
|
78
|
+
if (!(state in rawColors)) {
|
|
79
|
+
colors[state] = fallback
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
colors[state] = stateColorOption(rawColors[state], fallback, `colors.${state}`, warn)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const devices = isRecord(o["devices"]) ? o["devices"] : {}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
enabled: boolOption(o["enabled"], true),
|
|
89
|
+
host: stringOption(o["host"], DEFAULT_HOST),
|
|
90
|
+
port: intOption(o["port"], DEFAULT_PORT, 1, 65535, "port", warn),
|
|
91
|
+
colors,
|
|
92
|
+
idle: idleOption(o["idle"], warn),
|
|
93
|
+
brightness: numberOption(o["brightness"], 1, 0, 1, "brightness", warn),
|
|
94
|
+
pulse: boolOption(o["pulse"], false),
|
|
95
|
+
pulseFps: intOption(o["pulseFps"], 15, 1, 60, "pulseFps", warn),
|
|
96
|
+
debounceMs: intOption(o["debounceMs"], 120, 0, 5_000, "debounceMs", warn),
|
|
97
|
+
restoreDelayMs: intOption(o["restoreDelayMs"], 400, 0, 60_000, "restoreDelayMs", warn),
|
|
98
|
+
include: stringArrayOption(devices["include"], DEFAULT_INCLUDE, "devices.include", warn),
|
|
99
|
+
exclude: stringArrayOption(devices["exclude"], [], "devices.exclude", warn),
|
|
100
|
+
debug: boolOption(o["debug"], process.env["OPENCODE_OPENRGB_DEBUG"] === "1"),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stateColorOption(
|
|
105
|
+
value: unknown,
|
|
106
|
+
fallback: StateColor,
|
|
107
|
+
name: string,
|
|
108
|
+
warn: (m: string) => void,
|
|
109
|
+
): StateColor {
|
|
110
|
+
if (typeof value === "string" && BASELINE_ALIASES.has(value.trim().toLowerCase())) {
|
|
111
|
+
return { kind: "baseline" }
|
|
112
|
+
}
|
|
113
|
+
const parsed = parseColor(value)
|
|
114
|
+
if (parsed === null) {
|
|
115
|
+
warn(`invalid ${name} (${JSON.stringify(value)}); using default`)
|
|
116
|
+
return fallback
|
|
117
|
+
}
|
|
118
|
+
return { kind: "fixed", color: parsed }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function idleOption(value: unknown, warn: (m: string) => void): IdleBehaviour {
|
|
122
|
+
if (value === undefined || value === null) return { kind: "restore" }
|
|
123
|
+
if (typeof value === "string" && value.trim().toLowerCase() === "restore") {
|
|
124
|
+
return { kind: "restore" }
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseColor(value)
|
|
127
|
+
if (parsed === null) {
|
|
128
|
+
warn(`invalid idle (${JSON.stringify(value)}); using "restore"`)
|
|
129
|
+
return { kind: "restore" }
|
|
130
|
+
}
|
|
131
|
+
return { kind: "color", color: parsed }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/*---------------------------------------------------------------------------*\
|
|
135
|
+
| Device matching |
|
|
136
|
+
\*---------------------------------------------------------------------------*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A pattern matches a device if it equals the device's type name
|
|
140
|
+
* (e.g. `motherboard`) or glob-matches its product name (e.g. `ASUS*`).
|
|
141
|
+
* Matching is case-insensitive; `*` and `?` are the supported wildcards.
|
|
142
|
+
*/
|
|
143
|
+
export function deviceMatches(device: Device, patterns: readonly string[]): boolean {
|
|
144
|
+
return patterns.some((pattern) => {
|
|
145
|
+
const p = pattern.trim().toLowerCase()
|
|
146
|
+
if (p.length === 0) return false
|
|
147
|
+
if (p === "*") return true
|
|
148
|
+
if (p === device.typeName.toLowerCase()) return true
|
|
149
|
+
return globToRegExp(p).test(device.name.toLowerCase())
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function selectDevices(
|
|
154
|
+
devices: readonly Device[],
|
|
155
|
+
options: Pick<ResolvedOptions, "include" | "exclude">,
|
|
156
|
+
): Device[] {
|
|
157
|
+
return devices.filter(
|
|
158
|
+
(device) =>
|
|
159
|
+
deviceMatches(device, options.include) && !deviceMatches(device, options.exclude),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function globToRegExp(glob: string): RegExp {
|
|
164
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
165
|
+
const pattern = escaped.replace(/\*/g, ".*").replace(/\?/g, ".")
|
|
166
|
+
return new RegExp(`^${pattern}$`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/*---------------------------------------------------------------------------*\
|
|
170
|
+
| Primitive coercion |
|
|
171
|
+
\*---------------------------------------------------------------------------*/
|
|
172
|
+
|
|
173
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
174
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function boolOption(value: unknown, fallback: boolean): boolean {
|
|
178
|
+
return typeof value === "boolean" ? value : fallback
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stringOption(value: unknown, fallback: string): string {
|
|
182
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function numberOption(
|
|
186
|
+
value: unknown,
|
|
187
|
+
fallback: number,
|
|
188
|
+
min: number,
|
|
189
|
+
max: number,
|
|
190
|
+
name: string,
|
|
191
|
+
warn: (m: string) => void,
|
|
192
|
+
): number {
|
|
193
|
+
if (value === undefined) return fallback
|
|
194
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
195
|
+
warn(`invalid ${name} (${JSON.stringify(value)}); using ${fallback}`)
|
|
196
|
+
return fallback
|
|
197
|
+
}
|
|
198
|
+
if (value < min || value > max) {
|
|
199
|
+
const clamped = Math.max(min, Math.min(max, value))
|
|
200
|
+
warn(`${name} ${value} out of range [${min}, ${max}]; clamped to ${clamped}`)
|
|
201
|
+
return clamped
|
|
202
|
+
}
|
|
203
|
+
return value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function intOption(
|
|
207
|
+
value: unknown,
|
|
208
|
+
fallback: number,
|
|
209
|
+
min: number,
|
|
210
|
+
max: number,
|
|
211
|
+
name: string,
|
|
212
|
+
warn: (m: string) => void,
|
|
213
|
+
): number {
|
|
214
|
+
return Math.round(numberOption(value, fallback, min, max, name, warn))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stringArrayOption(
|
|
218
|
+
value: unknown,
|
|
219
|
+
fallback: string[],
|
|
220
|
+
name: string,
|
|
221
|
+
warn: (m: string) => void,
|
|
222
|
+
): string[] {
|
|
223
|
+
if (value === undefined) return [...fallback]
|
|
224
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
225
|
+
warn(`invalid ${name}; expected an array of strings. Using default`)
|
|
226
|
+
return [...fallback]
|
|
227
|
+
}
|
|
228
|
+
return (value as string[]).map((v) => v.trim()).filter((v) => v.length > 0)
|
|
229
|
+
}
|