@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/src/client.ts ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * A minimal, dependency-free OpenRGB SDK client.
3
+ *
4
+ * Design constraints, in priority order:
5
+ *
6
+ * 1. Never throw into the host process. If OpenRGB is not running, is
7
+ * restarted, or misbehaves, every operation degrades to a silent no-op and
8
+ * the client reconnects in the background.
9
+ * 2. Protocol 5 has no ACK packets, so writes are fire-and-forget. Only
10
+ * `REQUEST_*` packets have responses to await.
11
+ * 3. Device data is requested one device at a time, as the SDK documentation
12
+ * requires, which keeps response correlation trivial.
13
+ */
14
+
15
+ import fs from "node:fs"
16
+ import net from "node:net"
17
+
18
+ import {
19
+ CLIENT_PROTOCOL_VERSION,
20
+ DEFAULT_HOST,
21
+ DEFAULT_PORT,
22
+ type Device,
23
+ type Frame,
24
+ type Mode,
25
+ ModeFlag,
26
+ PacketId,
27
+ decodeDevice,
28
+ decodeFrames,
29
+ encodeClientName,
30
+ encodePacket,
31
+ encodeProtocolVersion,
32
+ encodeUpdateLeds,
33
+ encodeUpdateMode,
34
+ } from "./protocol.js"
35
+
36
+ export interface Logger {
37
+ debug(message: string): void
38
+ warn(message: string): void
39
+ }
40
+
41
+ export interface ClientOptions {
42
+ host?: string
43
+ port?: number
44
+ /** Name reported to the OpenRGB server; shows up in its client list. */
45
+ clientName?: string
46
+ logger?: Logger
47
+ /** Invoked after the device list is (re)loaded, including on reconnect. */
48
+ onDevicesReloaded?: (devices: Device[]) => void
49
+ }
50
+
51
+ const CONNECT_TIMEOUT_MS = 3_000
52
+ const RESPONSE_TIMEOUT_MS = 5_000
53
+ /** Protocol-0 servers never answer the version request; don't wait long. */
54
+ const VERSION_TIMEOUT_MS = 1_000
55
+ const RETRY_MIN_MS = 1_000
56
+ const RETRY_MAX_MS = 30_000
57
+
58
+ interface Waiter {
59
+ resolve: (frame: Frame) => void
60
+ reject: (error: Error) => void
61
+ timer: NodeJS.Timeout
62
+ }
63
+
64
+ const noopLogger: Logger = {
65
+ debug: () => {},
66
+ warn: () => {},
67
+ }
68
+
69
+ export class OpenRGBClient {
70
+ private readonly host: string
71
+ private readonly port: number
72
+ private readonly clientName: string
73
+ private readonly log: Logger
74
+ private readonly onDevicesReloaded: ((devices: Device[]) => void) | undefined
75
+
76
+ private socket: net.Socket | null = null
77
+ private rxBuf: Buffer = Buffer.alloc(0)
78
+ private waiters = new Map<number, Waiter[]>()
79
+
80
+ private connectPromise: Promise<boolean> | null = null
81
+ private retryTimer: NodeJS.Timeout | null = null
82
+ private retryDelay = RETRY_MIN_MS
83
+
84
+ private protocol = CLIENT_PROTOCOL_VERSION
85
+ private devices: Device[] = []
86
+ private ready = false
87
+ private disposed = false
88
+
89
+ /** Guards against overlapping device reloads triggered by rapid events. */
90
+ private reloading: Promise<void> | null = null
91
+
92
+ constructor(options: ClientOptions = {}) {
93
+ this.host = options.host ?? DEFAULT_HOST
94
+ this.port = options.port ?? DEFAULT_PORT
95
+ this.clientName = options.clientName ?? "opencode"
96
+ this.log = options.logger ?? noopLogger
97
+ this.onDevicesReloaded = options.onDevicesReloaded
98
+ }
99
+
100
+ get isReady(): boolean {
101
+ return this.ready
102
+ }
103
+
104
+ get protocolVersion(): number {
105
+ return this.protocol
106
+ }
107
+
108
+ getDevices(): readonly Device[] {
109
+ return this.devices
110
+ }
111
+
112
+ /*-------------------------------------------------------------------------*\
113
+ | Connection lifecycle |
114
+ \*-------------------------------------------------------------------------*/
115
+
116
+ /** Resolves true when connected and the device list is loaded. */
117
+ async ensureReady(): Promise<boolean> {
118
+ if (this.disposed) return false
119
+ if (this.ready) return true
120
+ if (this.connectPromise) return this.connectPromise
121
+
122
+ this.connectPromise = this.connect().finally(() => {
123
+ this.connectPromise = null
124
+ })
125
+ return this.connectPromise
126
+ }
127
+
128
+ private async connect(): Promise<boolean> {
129
+ if (this.disposed) return false
130
+
131
+ let socket: net.Socket
132
+ try {
133
+ socket = await this.openSocket()
134
+ } catch (error) {
135
+ this.log.debug(`connect failed: ${describe(error)}`)
136
+ this.scheduleRetry()
137
+ return false
138
+ }
139
+
140
+ this.socket = socket
141
+ this.rxBuf = Buffer.alloc(0)
142
+
143
+ socket.on("data", (chunk) => this.onData(chunk))
144
+ socket.on("error", (error) => this.onDisconnect(`socket error: ${describe(error)}`))
145
+ socket.on("close", () => this.onDisconnect("socket closed"))
146
+
147
+ try {
148
+ // The server does not reply to SET_CLIENT_NAME.
149
+ this.send(0, PacketId.SET_CLIENT_NAME, encodeClientName(this.clientName))
150
+
151
+ this.protocol = await this.negotiateProtocol()
152
+ this.log.debug(`connected, protocol ${this.protocol}`)
153
+
154
+ await this.loadDevices()
155
+
156
+ this.ready = true
157
+ this.retryDelay = RETRY_MIN_MS
158
+ return true
159
+ } catch (error) {
160
+ this.log.debug(`handshake failed: ${describe(error)}`)
161
+ this.teardownSocket()
162
+ this.scheduleRetry()
163
+ return false
164
+ }
165
+ }
166
+
167
+ private openSocket(): Promise<net.Socket> {
168
+ return new Promise((resolve, reject) => {
169
+ const socket = net.connect({ host: this.host, port: this.port })
170
+ socket.setNoDelay(true)
171
+ // Do not keep the host process alive on our account.
172
+ socket.unref()
173
+
174
+ const timer = setTimeout(() => {
175
+ socket.destroy()
176
+ reject(new Error(`connect timeout after ${CONNECT_TIMEOUT_MS}ms`))
177
+ }, CONNECT_TIMEOUT_MS)
178
+ timer.unref?.()
179
+
180
+ socket.once("connect", () => {
181
+ clearTimeout(timer)
182
+ socket.removeAllListeners("error")
183
+ resolve(socket)
184
+ })
185
+ socket.once("error", (error) => {
186
+ clearTimeout(timer)
187
+ reject(error)
188
+ })
189
+ })
190
+ }
191
+
192
+ private async negotiateProtocol(): Promise<number> {
193
+ this.send(0, PacketId.REQUEST_PROTOCOL_VERSION, encodeProtocolVersion(CLIENT_PROTOCOL_VERSION))
194
+ try {
195
+ const frame = await this.waitFor(PacketId.REQUEST_PROTOCOL_VERSION, VERSION_TIMEOUT_MS)
196
+ const server = frame.data.length >= 4 ? frame.data.readUInt32LE(0) : 0
197
+ return Math.min(server, CLIENT_PROTOCOL_VERSION)
198
+ } catch {
199
+ // Protocol-0 servers stay silent here. That is a valid answer.
200
+ this.log.debug("no protocol version response; assuming protocol 0")
201
+ return 0
202
+ }
203
+ }
204
+
205
+ private onDisconnect(reason: string): void {
206
+ if (!this.socket && !this.ready) return
207
+ this.log.debug(`disconnected: ${reason}`)
208
+ this.teardownSocket()
209
+ if (!this.disposed) this.scheduleRetry()
210
+ }
211
+
212
+ private teardownSocket(): void {
213
+ this.ready = false
214
+ this.devices = []
215
+ this.rxBuf = Buffer.alloc(0)
216
+
217
+ for (const list of this.waiters.values()) {
218
+ for (const waiter of list) {
219
+ clearTimeout(waiter.timer)
220
+ waiter.reject(new Error("connection lost"))
221
+ }
222
+ }
223
+ this.waiters.clear()
224
+
225
+ const socket = this.socket
226
+ this.socket = null
227
+ if (socket) {
228
+ socket.removeAllListeners()
229
+ socket.destroy()
230
+ }
231
+ }
232
+
233
+ private scheduleRetry(): void {
234
+ if (this.disposed || this.retryTimer) return
235
+
236
+ const delay = this.retryDelay
237
+ this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_MS)
238
+
239
+ this.retryTimer = setTimeout(() => {
240
+ this.retryTimer = null
241
+ void this.ensureReady()
242
+ }, delay)
243
+ this.retryTimer.unref?.()
244
+ }
245
+
246
+ dispose(): void {
247
+ this.disposed = true
248
+ if (this.retryTimer) {
249
+ clearTimeout(this.retryTimer)
250
+ this.retryTimer = null
251
+ }
252
+ this.teardownSocket()
253
+ }
254
+
255
+ /*-------------------------------------------------------------------------*\
256
+ | Framing |
257
+ \*-------------------------------------------------------------------------*/
258
+
259
+ private onData(chunk: Buffer): void {
260
+ this.rxBuf = this.rxBuf.length === 0 ? chunk : Buffer.concat([this.rxBuf, chunk])
261
+
262
+ let frames: Frame[]
263
+ try {
264
+ const decoded = decodeFrames(this.rxBuf)
265
+ frames = decoded.frames
266
+ this.rxBuf = decoded.rest
267
+ } catch (error) {
268
+ this.log.warn(`frame decode failed: ${describe(error)}`)
269
+ this.rxBuf = Buffer.alloc(0)
270
+ return
271
+ }
272
+
273
+ for (const frame of frames) {
274
+ if (frame.pktId === PacketId.DEVICE_LIST_UPDATED) {
275
+ this.log.debug("server reports device list updated")
276
+ void this.reloadDevices()
277
+ continue
278
+ }
279
+
280
+ const list = this.waiters.get(frame.pktId)
281
+ const waiter = list?.shift()
282
+ if (!waiter) continue
283
+ clearTimeout(waiter.timer)
284
+ waiter.resolve(frame)
285
+ }
286
+ }
287
+
288
+ private waitFor(pktId: number, timeoutMs = RESPONSE_TIMEOUT_MS): Promise<Frame> {
289
+ return new Promise<Frame>((resolve, reject) => {
290
+ const timer = setTimeout(() => {
291
+ const list = this.waiters.get(pktId)
292
+ if (list) {
293
+ const idx = list.findIndex((w) => w.timer === timer)
294
+ if (idx !== -1) list.splice(idx, 1)
295
+ }
296
+ reject(new Error(`timeout waiting for packet ${pktId}`))
297
+ }, timeoutMs)
298
+ timer.unref?.()
299
+
300
+ const list = this.waiters.get(pktId)
301
+ if (list) list.push({ resolve, reject, timer })
302
+ else this.waiters.set(pktId, [{ resolve, reject, timer }])
303
+ })
304
+ }
305
+
306
+ private send(devId: number, pktId: number, data?: Buffer, sync = false): boolean {
307
+ const socket = this.socket
308
+ if (!socket || socket.destroyed) return false
309
+
310
+ const packet = encodePacket(devId, pktId, data)
311
+
312
+ if (sync && this.writeSync(packet)) return true
313
+
314
+ try {
315
+ socket.write(packet)
316
+ return true
317
+ } catch (error) {
318
+ this.log.debug(`write failed: ${describe(error)}`)
319
+ return false
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Write straight to the socket's file descriptor, bypassing libuv's queue.
325
+ *
326
+ * This exists for one reason: restoring the user's lighting from a
327
+ * `process.on("exit")` handler. Normal `socket.write` calls only enqueue
328
+ * data, and the process terminates before the event loop can flush them, so
329
+ * the restore silently never reaches OpenRGB and the lights stay stuck on
330
+ * whatever the last state color was.
331
+ *
332
+ * Reaching for `_handle.fd` is private API, so every failure path falls back
333
+ * to a queued write. Returns true only if the bytes were fully written.
334
+ */
335
+ private writeSync(packet: Buffer): boolean {
336
+ const handle = (this.socket as unknown as { _handle?: { fd?: unknown } } | null)?._handle
337
+ const fd = handle?.fd
338
+ if (typeof fd !== "number" || fd < 0) return false
339
+
340
+ let written = 0
341
+ // Sockets are non-blocking, so a full kernel buffer surfaces as EAGAIN.
342
+ // Packets here are tiny (tens of bytes), so a short bounded retry is
343
+ // plenty, and we must not spin forever inside an exit handler.
344
+ for (let attempt = 0; attempt < 64 && written < packet.length; attempt++) {
345
+ try {
346
+ written += fs.writeSync(fd, packet, written, packet.length - written)
347
+ } catch (error) {
348
+ const code = (error as NodeJS.ErrnoException).code
349
+ if (code === "EAGAIN") continue
350
+ return false
351
+ }
352
+ }
353
+
354
+ return written === packet.length
355
+ }
356
+
357
+ /*-------------------------------------------------------------------------*\
358
+ | Device discovery |
359
+ \*-------------------------------------------------------------------------*/
360
+
361
+ private async loadDevices(): Promise<void> {
362
+ this.send(0, PacketId.REQUEST_CONTROLLER_COUNT)
363
+ const countFrame = await this.waitFor(PacketId.REQUEST_CONTROLLER_COUNT)
364
+ const count = countFrame.data.length >= 4 ? countFrame.data.readUInt32LE(0) : 0
365
+
366
+ const devices: Device[] = []
367
+ for (let i = 0; i < count; i++) {
368
+ // Protocol 0 expects an empty body here; 1+ expects the negotiated version.
369
+ const body = this.protocol >= 1 ? encodeProtocolVersion(this.protocol) : undefined
370
+ this.send(i, PacketId.REQUEST_CONTROLLER_DATA, body)
371
+ const frame = await this.waitFor(PacketId.REQUEST_CONTROLLER_DATA)
372
+ try {
373
+ devices.push(decodeDevice(frame.data, this.protocol, i))
374
+ } catch (error) {
375
+ this.log.warn(`failed to decode device ${i}: ${describe(error)}`)
376
+ }
377
+ }
378
+
379
+ this.devices = devices
380
+ this.log.debug(`loaded ${devices.length} device(s)`)
381
+ this.onDevicesReloaded?.(devices)
382
+ }
383
+
384
+ /** Re-read the device list, e.g. after DEVICE_LIST_UPDATED. */
385
+ async reloadDevices(): Promise<void> {
386
+ if (this.reloading) return this.reloading
387
+ this.reloading = (async () => {
388
+ if (!this.socket || this.socket.destroyed) return
389
+ try {
390
+ await this.loadDevices()
391
+ } catch (error) {
392
+ this.log.warn(`device reload failed: ${describe(error)}`)
393
+ }
394
+ })().finally(() => {
395
+ this.reloading = null
396
+ })
397
+ return this.reloading
398
+ }
399
+
400
+ /*-------------------------------------------------------------------------*\
401
+ | Writes (fire-and-forget on protocol 5) |
402
+ \*-------------------------------------------------------------------------*/
403
+
404
+ /**
405
+ * @param sync Write synchronously, bypassing the event loop. Required when
406
+ * called from a `process.on("exit")` handler, where queued writes never
407
+ * flush. Do not use on the hot path.
408
+ */
409
+ updateLeds(deviceIndex: number, colors: readonly number[], sync = false): boolean {
410
+ if (colors.length === 0) return false
411
+ return this.send(
412
+ deviceIndex,
413
+ PacketId.RGBCONTROLLER_UPDATELEDS,
414
+ encodeUpdateLeds(colors),
415
+ sync,
416
+ )
417
+ }
418
+
419
+ /** @param sync See {@link OpenRGBClient.updateLeds}. */
420
+ updateMode(deviceIndex: number, modeIndex: number, mode: Mode, sync = false): boolean {
421
+ return this.send(
422
+ deviceIndex,
423
+ PacketId.RGBCONTROLLER_UPDATEMODE,
424
+ encodeUpdateMode(modeIndex, mode, this.protocol),
425
+ sync,
426
+ )
427
+ }
428
+
429
+ setCustomMode(deviceIndex: number): boolean {
430
+ return this.send(deviceIndex, PacketId.RGBCONTROLLER_SETCUSTOMMODE)
431
+ }
432
+ }
433
+
434
+ /*---------------------------------------------------------------------------*\
435
+ | Helpers |
436
+ \*---------------------------------------------------------------------------*/
437
+
438
+ /**
439
+ * Pick the mode to use for direct per-LED control.
440
+ *
441
+ * Preference order: a mode literally named "Direct", then any mode advertising
442
+ * per-LED colors, then nothing (in which case the caller should fall back to
443
+ * `SETCUSTOMMODE`).
444
+ */
445
+ export function findDirectMode(device: Device): { index: number; mode: Mode } | null {
446
+ const named = device.modes.findIndex((m) => /^direct$/i.test(m.name.trim()))
447
+ if (named !== -1) {
448
+ const mode = device.modes[named]
449
+ if (mode) return { index: named, mode }
450
+ }
451
+
452
+ const perLed = device.modes.findIndex((m) => (m.flags & ModeFlag.HAS_PER_LED_COLOR) !== 0)
453
+ if (perLed !== -1) {
454
+ const mode = device.modes[perLed]
455
+ if (mode) return { index: perLed, mode }
456
+ }
457
+
458
+ return null
459
+ }
460
+
461
+ export function describe(error: unknown): string {
462
+ if (error instanceof Error) return error.message
463
+ return String(error)
464
+ }