@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
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
|
+
}
|