1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { defer } from "@clo/lib/ts.ts";
4import type { Dispose, Timer } from "@clo/lib/ts.ts";
5import type { Mac } from "./Mac";
6
7const SPEED_EDITOR_VENDOR_ID = 0x1edb;
8const SPEED_EDITOR_PRODUCT_ID = 0xda0e;
9
10const keyIds = [
11 "smartInsert",
12 "append",
13 "rippleOverwrite",
14 "closeUp",
15 "placeOnTop",
16 "sourceOverwrite",
17 "in",
18 "out",
19 "trimIn",
20 "trimOut",
21 "roll",
22 "slipSource",
23 "slipDestination",
24 "transitionDuration",
25 "cut",
26 "dissolve",
27 "smoothCut",
28 "source",
29 "timeline",
30 "shuttle",
31 "jog",
32 "scroll",
33 "escape",
34 "syncBin",
35 "audioLevel",
36 "fullView",
37 "transition",
38 "split",
39 "snap",
40 "rippleDelete",
41 "cam1",
42 "cam2",
43 "cam3",
44 "cam4",
45 "cam5",
46 "cam6",
47 "cam7",
48 "cam8",
49 "cam9",
50 "liveOverwrite",
51 "videoOnly",
52 "audioOnly",
53 "stopPlay",
54] as const;
55
56const ledIds = [
57 "closeUp",
58 "cut",
59 "dissolve",
60 "smoothCut",
61 "transition",
62 "snap",
63 "cam7",
64 "cam8",
65 "cam9",
66 "liveOverwrite",
67 "cam4",
68 "cam5",
69 "cam6",
70 "videoOnly",
71 "cam1",
72 "cam2",
73 "cam3",
74 "audioOnly",
75] as const;
76
77const jogLeds = ["jog", "shuttle", "scroll"] as const;
78
79const jogModes = [
80 "relative",
81 "relative-normalized",
82 "absolute-continuous",
83 "absolute-deadzero",
84] as const;
85
86/**
87 * Node.js Bindings for DaVinci Resolve Speed Editor.
88 */
89export class SpeedEditor extends Events<SpeedEditor.EventMap> {
90 static keys = keyIds;
91 static leds = ledIds;
92 static jogLeds = jogLeds;
93 static jogModes = jogModes;
94
95 #options: Required<SpeedEditor.Options>;
96 #device: import("node-hid").HID | null = null;
97 #deviceInfo: SpeedEditor.DeviceInfo | null = null;
98 #ready = false;
99 #closed = false;
100 #stopHotplugMonitoring: (() => void) | null = null;
101 #refreshing: Promise<void> | null = null;
102 #refreshRequested = false;
103 #leds = new Set<SpeedEditor.Led>();
104 #jogLeds = new Set<SpeedEditor.JogLed>();
105 #jogMode: SpeedEditor.JogMode = "relative";
106 #normalizedJogActive = false;
107 #normalizedJogBufferedDistance = 0;
108 #normalizedJogBufferedValue = 0;
109 #normalizedJogResetTimer: Timer | null = null;
110 #activeKeys = new Set<SpeedEditor.Key>();
111 #pendingKeypressTimers = new Map<SpeedEditor.Key, Timer>();
112 #doublePressActiveKeys = new Set<SpeedEditor.Key>();
113 #doublePressListeners = new Map<SpeedEditor.Key, Set<() => void>>();
114
115 private constructor(options: SpeedEditor.Options = {}) {
116 super();
117 this.#options = {
118 vendorId: options.vendorId ?? SPEED_EDITOR_VENDOR_ID,
119 productId: options.productId ?? SPEED_EDITOR_PRODUCT_ID,
120 path: options.path ?? null,
121 nonExclusive: options.nonExclusive ?? false,
122 };
123 }
124
125 static async open(options: SpeedEditor.Options = {}) {
126 const editor = new SpeedEditor(options);
127 await editor.#start();
128 return editor;
129 }
130
131 static async listDevices(
132 options: Pick<SpeedEditor.Options, "vendorId" | "productId"> = {},
133 ): Promise<SpeedEditor.DeviceInfo[]> {
134 const { devices } = await import("node-hid");
135 const vendorId = options.vendorId ?? SPEED_EDITOR_VENDOR_ID;
136 const productId = options.productId ?? SPEED_EDITOR_PRODUCT_ID;
137
138 return devices(vendorId, productId).map((device) => ({ ...device }));
139 }
140
141 #authenticate(device: import("node-hid").HID): number {
142 return authenticateDevice(device);
143 }
144
145 setLeds(states: Iterable<SpeedEditor.Led>) {
146 this.#leds = new Set(states);
147 if (this.#device) {
148 writeLedState(this.#device, this.#leds);
149 }
150 }
151
152 setJogLeds(states: Iterable<SpeedEditor.JogLed>) {
153 this.#jogLeds = new Set(states);
154 if (this.#device) {
155 writeJogLedState(this.#device, this.#jogLeds);
156 }
157 }
158
159 setJogMode(mode: SpeedEditor.JogMode) {
160 if (mode !== this.#jogMode) {
161 this.#resetNormalizedJogTracking();
162 }
163 this.#jogMode = mode;
164 if (this.#device) {
165 writeJogMode(this.#device, mode);
166 }
167 }
168
169 leds = new Proxy({} as Record<SpeedEditor.Led, boolean>, {
170 get: (_, key) => {
171 return this.#leds.has(key as SpeedEditor.Led);
172 },
173 set: (_, key, value) => {
174 if (value) this.#leds.add(key as SpeedEditor.Led);
175 else this.#leds.delete(key as SpeedEditor.Led);
176 this.setLeds(Array.from(this.#leds));
177 return true;
178 },
179 });
180
181 jogLeds = new Proxy({} as Record<SpeedEditor.JogLed, boolean>, {
182 get: (_, key) => {
183 return this.#jogLeds.has(key as SpeedEditor.JogLed);
184 },
185 set: (_, key, value) => {
186 return true;
187 },
188 });
189
190 keys = new Proxy({} as Record<SpeedEditor.Key, boolean>, {
191 get: (_, key) => {
192 return this.#activeKeys.has(key as SpeedEditor.Key);
193 },
194 set: (_, key, value) => {
195 return true;
196 },
197 });
198
199 get jogMode(): SpeedEditor.JogMode {
200 return this.#jogMode;
201 }
202
203 get connected(): boolean {
204 return this.#ready;
205 }
206
207 get deviceInfo(): SpeedEditor.DeviceInfo | null {
208 return this.#deviceInfo && { ...this.#deviceInfo };
209 }
210
211 onPress(key: SpeedEditor.Key, listener: () => void): Dispose {
212 return this.on("keypress", (code) => {
213 if (key === code) listener();
214 });
215 }
216
217 onDoublePress(key: SpeedEditor.Key, listener: () => void): Dispose {
218 const listeners = this.#doublePressListeners.get(key)
219 ?? new Set<() => void>();
220 listeners.add(listener);
221 this.#doublePressListeners.set(key, listeners);
222
223 return defer(() => {
224 const current = this.#doublePressListeners.get(key);
225 if (!current) return;
226 current.delete(listener);
227 if (current.size === 0) {
228 this.#doublePressListeners.delete(key);
229 }
230 });
231 }
232
233 close() {
234 if (this.#closed) return;
235 this.#closed = true;
236 this.#ready = false;
237 this.#refreshRequested = false;
238 this.#stopHotplugMonitoring?.();
239 this.#stopHotplugMonitoring = null;
240 this.#disconnect(false);
241 this.emit("close");
242 }
243
244 async #start() {
245 await this.#startHotplugMonitoring();
246 await this.#refreshConnection();
247 }
248
249 async #startHotplugMonitoring() {
250 const { usb } = await import("usb");
251 const onHotplug = (device: import("usb").Device) => {
252 if (!this.#matchesHotplugDevice(device)) {
253 return;
254 }
255 void this.#refreshConnection();
256 };
257
258 usb.on("attach", onHotplug);
259 usb.on("detach", onHotplug);
260 usb.unrefHotplugEvents();
261
262 this.#stopHotplugMonitoring = () => {
263 usb.off("attach", onHotplug);
264 usb.off("detach", onHotplug);
265 };
266 }
267
268 #matchesHotplugDevice(device: import("usb").Device) {
269 const descriptor = device.deviceDescriptor;
270 return descriptor.idVendor === this.#options.vendorId
271 && descriptor.idProduct === this.#options.productId;
272 }
273
274 async #refreshConnection() {
275 if (this.#closed) {
276 return;
277 }
278
279 this.#refreshRequested = true;
280 if (this.#refreshing) {
281 await this.#refreshing;
282 return;
283 }
284
285 this.#refreshing = (async () => {
286 while (this.#refreshRequested && !this.#closed) {
287 this.#refreshRequested = false;
288
289 try {
290 const devices = await SpeedEditor.listDevices({
291 vendorId: this.#options.vendorId,
292 productId: this.#options.productId,
293 });
294 await this.#reconcileConnection(devices);
295 } catch (error) {
296 this.#emitAsyncError(error);
297 }
298 }
299 })().finally(() => {
300 this.#refreshing = null;
301 });
302
303 await this.#refreshing;
304 }
305
306 async #reconcileConnection(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
307 if (this.#closed) {
308 return;
309 }
310
311 const currentKey = getDeviceKey(this.#deviceInfo);
312 const currentStillPresent = currentKey !== null
313 && devices.some((device) => getDeviceKey(device) === currentKey);
314
315 if (this.#device && !currentStillPresent) {
316 this.#disconnect(true);
317 }
318
319 if (this.#device) {
320 return;
321 }
322
323 const nextDevice = this.#selectDevice(devices);
324 if (!nextDevice) {
325 return;
326 }
327
328 await this.#connect(nextDevice);
329 }
330
331 #selectDevice(devices: ReadonlyArray<SpeedEditor.DeviceInfo>) {
332 if (this.#options.path) {
333 return devices.find((device) => device.path === this.#options.path)
334 ?? null;
335 }
336
337 return devices[0] ?? null;
338 }
339
340 async #connect(
341 deviceInfo: SpeedEditor.DeviceInfo,
342 ): Promise<SpeedEditor.DeviceInfo | null> {
343 const { HID } = await import("node-hid");
344 let device: import("node-hid").HID | null = null;
345
346 try {
347 device = this.#options.path
348 ? new HID(this.#options.path, {
349 nonExclusive: this.#options.nonExclusive,
350 })
351 : new HID(deviceInfo.vendorId, deviceInfo.productId, {
352 nonExclusive: this.#options.nonExclusive,
353 });
354
355 this.#device = device;
356 this.#authenticate(device);
357 if (this.#closed || this.#device !== device) {
358 closeDeviceHandle(device);
359 return null;
360 }
361
362 device.on("data", (report) => {
363 if (this.#device === device) {
364 this.#handleReport(report);
365 }
366 });
367 device.on("error", (error) => {
368 if (this.#device === device) {
369 this.#handleDeviceError(error);
370 }
371 });
372
373 writeLedState(device, this.#leds);
374 writeJogLedState(device, this.#jogLeds);
375 writeJogMode(device, this.#jogMode);
376
377 const info = copyDeviceInfo(device.getDeviceInfo());
378 this.#deviceInfo = info;
379 this.#ready = true;
380 this.emit("connect", info);
381 return info;
382 } catch (error) {
383 if (this.#device === device) {
384 this.#disconnect(false);
385 } else if (device) {
386 closeDeviceHandle(device);
387 }
388
389 if (!isRecoverableDeviceError(error)) {
390 this.#emitAsyncError(error);
391 }
392 return null;
393 }
394 }
395
396 #handleReport(report: number[] | Buffer) {
397 const bytes = Uint8Array.from(report);
398 const reportId = bytes[0];
399
400 if (reportId === 0x03) {
401 const modeCode = bytes[1];
402 const mode = JOG_MODE_BY_CODE.get(modeCode);
403 if (!mode) return;
404 const rawValue = getInt32LE(bytes, 2);
405 const value = mode === "relative" ? rawValue / 360 : rawValue;
406 if (mode === "relative" && this.#jogMode === "relative-normalized") {
407 this.#handleNormalizedRelativeJog(value);
408 return;
409 }
410 this.emit("jog", { mode, value });
411 return;
412 }
413
414 if (reportId === 0x04) {
415 const keys: SpeedEditor.Key[] = [];
416 for (let index = 0; index < 6; index += 1) {
417 const code = getUint16LE(bytes, 1 + index * 2);
418 if (code === 0) {
419 continue;
420 }
421 const key = KEY_BY_CODE.get(code);
422 if (key) keys.push(key);
423 }
424 this.#applyKeyState(keys);
425 this.emit("key", keys);
426 return;
427 }
428
429 if (reportId === 0x07) {
430 const percent = bytes[2] ?? 0;
431 this.emit("battery", {
432 charging: (bytes[1] ?? 0) === 1,
433 level: Math.max(0, Math.min(percent, 100)) / 100,
434 percent,
435 });
436 return;
437 }
438 }
439
440 #handleDeviceError(error: unknown) {
441 this.#disconnect(true);
442 if (!isRecoverableDeviceError(error)) {
443 this.#emitAsyncError(error);
444 }
445 void this.#refreshConnection();
446 }
447
448 #emitAsyncError(error: unknown) {
449 queueMicrotask(() => {
450 this.emit("error", error);
451 });
452 }
453
454 #disconnect(emitEvent: boolean) {
455 const device = this.#device;
456 const info = this.#deviceInfo && { ...this.#deviceInfo };
457
458 this.#device = null;
459 this.#deviceInfo = null;
460 this.#ready = false;
461 if (device) {
462 closeDeviceHandle(device);
463 }
464 this.#resetNormalizedJogTracking();
465 this.#resetKeyTracking();
466
467 if (emitEvent && info) {
468 this.emit("disconnect", info);
469 }
470 }
471
472 #handleNormalizedRelativeJog(value: number) {
473 if (value === 0) {
474 return;
475 }
476
477 if (this.#normalizedJogActive) {
478 this.#scheduleNormalizedJogReset();
479 this.emit("jog", { mode: "relative-normalized", value });
480 return;
481 }
482
483 if (!this.#normalizedJogResetTimer) {
484 this.#scheduleNormalizedJogReset();
485 }
486 this.#normalizedJogBufferedValue += value;
487 this.#normalizedJogBufferedDistance += Math.abs(value);
488 if (this.#normalizedJogBufferedDistance < NORMALIZED_JOG_THRESHOLD) {
489 return;
490 }
491
492 this.#normalizedJogActive = true;
493 const bufferedValue = this.#normalizedJogBufferedValue;
494 this.#normalizedJogBufferedValue = 0;
495 this.#normalizedJogBufferedDistance = 0;
496 this.#scheduleNormalizedJogReset();
497 this.emit("jog", { mode: "relative-normalized", value: bufferedValue });
498 }
499
500 #scheduleNormalizedJogReset() {
501 if (this.#normalizedJogResetTimer) {
502 clearTimeout(this.#normalizedJogResetTimer);
503 }
504
505 this.#normalizedJogResetTimer = setTimeout(() => {
506 this.#normalizedJogResetTimer = null;
507 this.#normalizedJogActive = false;
508 this.#normalizedJogBufferedDistance = 0;
509 this.#normalizedJogBufferedValue = 0;
510 }, NORMALIZED_JOG_IDLE_RESET_MS);
511 }
512
513 #resetNormalizedJogTracking() {
514 if (this.#normalizedJogResetTimer) {
515 clearTimeout(this.#normalizedJogResetTimer);
516 this.#normalizedJogResetTimer = null;
517 }
518 this.#normalizedJogActive = false;
519 this.#normalizedJogBufferedDistance = 0;
520 this.#normalizedJogBufferedValue = 0;
521 }
522
523 #applyKeyState(keys: ReadonlyArray<SpeedEditor.Key>) {
524 const nextKeys = new Set(keys);
525 const releasedKeys: SpeedEditor.Key[] = [];
526 const pressedKeys: SpeedEditor.Key[] = [];
527
528 for (const key of this.#activeKeys) {
529 if (!nextKeys.has(key)) {
530 releasedKeys.push(key);
531 }
532 }
533
534 for (const key of nextKeys) {
535 if (!this.#activeKeys.has(key)) {
536 pressedKeys.push(key);
537 }
538 }
539
540 this.#activeKeys = nextKeys;
541
542 for (const key of releasedKeys) {
543 this.emit("keyup", key);
544 this.#handleKeyRelease(key);
545 }
546
547 for (const key of pressedKeys) {
548 this.emit("keydown", key);
549 this.#handleKeyPressStart(key);
550 }
551 }
552
553 #handleKeyPressStart(key: SpeedEditor.Key) {
554 if (!this.#isDoublePressTracked(key)) {
555 this.emit("keypress", key);
556 return;
557 }
558
559 const pendingKeypress = this.#pendingKeypressTimers.get(key);
560 if (!pendingKeypress) {
561 return;
562 }
563
564 clearTimeout(pendingKeypress);
565 this.#pendingKeypressTimers.delete(key);
566 this.#doublePressActiveKeys.add(key);
567
568 const listeners = this.#doublePressListeners.get(key);
569 if (!listeners) {
570 return;
571 }
572 for (const listener of listeners) {
573 listener();
574 }
575 }
576
577 #handleKeyRelease(key: SpeedEditor.Key) {
578 if (!this.#isDoublePressTracked(key)) {
579 return;
580 }
581
582 if (this.#doublePressActiveKeys.delete(key)) {
583 return;
584 }
585
586 const pendingKeypress = this.#pendingKeypressTimers.get(key);
587 if (pendingKeypress) {
588 clearTimeout(pendingKeypress);
589 }
590
591 this.#pendingKeypressTimers.set(
592 key,
593 setTimeout(() => {
594 this.#pendingKeypressTimers.delete(key);
595 this.emit("keypress", key);
596 }, DOUBLE_PRESS_WINDOW_MS),
597 );
598 }
599
600 #isDoublePressTracked(key: SpeedEditor.Key) {
601 return (this.#doublePressListeners.get(key)?.size ?? 0) > 0;
602 }
603
604 #resetKeyTracking() {
605 for (const timer of this.#pendingKeypressTimers.values()) {
606 clearTimeout(timer);
607 }
608 this.#pendingKeypressTimers.clear();
609 this.#doublePressActiveKeys.clear();
610 this.#activeKeys.clear();
611 }
612
613 static camNumbersToNumpad(
614 speededitor: Pick<SpeedEditor, "onPress">,
615 mac: Pick<Mac, "pressKey">,
616 ) {
617 speededitor.onPress("cam1", () => mac.pressKey("numpad1"));
618 speededitor.onPress("cam2", () => mac.pressKey("numpad2"));
619 speededitor.onPress("cam3", () => mac.pressKey("numpad3"));
620 speededitor.onPress("cam4", () => mac.pressKey("numpad4"));
621 speededitor.onPress("cam5", () => mac.pressKey("numpad5"));
622 speededitor.onPress("cam6", () => mac.pressKey("numpad6"));
623 speededitor.onPress("cam7", () => mac.pressKey("numpad7"));
624 speededitor.onPress("cam8", () => mac.pressKey("numpad8"));
625 speededitor.onPress("cam9", () => mac.pressKey("numpad9"));
626 speededitor.onPress("liveOverwrite", () => mac.pressKey("numpad0"));
627 }
628}
629
630export declare namespace SpeedEditor {
631 export type JogMode = typeof jogModes[number];
632 export type Key = typeof keyIds[number];
633 export type Led = typeof ledIds[number];
634 export type JogLed = typeof jogLeds[number];
635 export type JogKey = Extract<Key, JogLed>;
636 export type DeviceInfo = import("node-hid").Device;
637
638 export interface Options {
639 vendorId?: number;
640 productId?: number;
641 path?: string | null;
642 nonExclusive?: boolean;
643 }
644
645 export type EventMap = {
646 "close": [];
647 "connect": [deviceInfo: DeviceInfo];
648 "disconnect": [deviceInfo: DeviceInfo];
649 "error": [error: unknown];
650 "jog": [event: { mode: JogMode; value?: number }];
651 "key": [activeKeys: ReadonlyArray<Key>];
652 "keydown": [key: Key];
653 "keyup": [key: Key];
654 "keypress": [key: Key];
655 "battery": [event: {
656 charging: boolean;
657 /** Zero to one. */
658 level: number;
659 /** Zero to one hundred. */
660 percent: number;
661 }];
662 };
663}
664
665const UINT64_MASK = 0xffff_ffff_ffff_ffffn;
666const AUTH_MASK = 0xa79a63f585d37bf0n;
667const AUTH_EVEN_TABLE = [
668 0x3ae1206f97c10bc8n,
669 0x2a9ab32bebf244c6n,
670 0x20a6f8b8df9adf0an,
671 0xaf80ece52cfc1719n,
672 0xec2ee2f7414fd151n,
673 0xb055adfd73344a15n,
674 0xa63d2e3059001187n,
675 0x751bf623f42e0dden,
676] as const;
677const AUTH_ODD_TABLE = [
678 0x3e22b34f502e7fden,
679 0x24656b981875ab1cn,
680 0xa17f3456df7bf8c3n,
681 0x6df72e1941aef698n,
682 0x72226f011e66ab94n,
683 0x3831a3c606296b42n,
684 0xfd7ff81881332c89n,
685 0x61a3f6474ff236c6n,
686] as const;
687
688const KEY_BY_CODE = new Map<number, SpeedEditor.Key>([
689 [0x01, "smartInsert"],
690 [0x02, "append"],
691 [0x03, "rippleOverwrite"],
692 [0x04, "closeUp"],
693 [0x05, "placeOnTop"],
694 [0x06, "sourceOverwrite"],
695 [0x07, "in"],
696 [0x08, "out"],
697 [0x09, "trimIn"],
698 [0x0a, "trimOut"],
699 [0x0b, "roll"],
700 [0x0c, "slipSource"],
701 [0x0d, "slipDestination"],
702 [0x0e, "transitionDuration"],
703 [0x0f, "cut"],
704 [0x10, "dissolve"],
705 [0x11, "smoothCut"],
706 [0x1a, "source"],
707 [0x1b, "timeline"],
708 [0x1c, "shuttle"],
709 [0x1d, "jog"],
710 [0x1e, "scroll"],
711 [0x1f, "syncBin"],
712 [0x22, "transition"],
713 [0x25, "videoOnly"],
714 [0x26, "audioOnly"],
715 [0x2b, "rippleDelete"],
716 [0x2c, "audioLevel"],
717 [0x2d, "fullView"],
718 [0x2e, "snap"],
719 [0x2f, "split"],
720 [0x30, "liveOverwrite"],
721 [0x31, "escape"],
722 [0x33, "cam1"],
723 [0x34, "cam2"],
724 [0x35, "cam3"],
725 [0x36, "cam4"],
726 [0x37, "cam5"],
727 [0x38, "cam6"],
728 [0x39, "cam7"],
729 [0x3a, "cam8"],
730 [0x3b, "cam9"],
731 [0x3c, "stopPlay"],
732]);
733
734const LED_BIT_BY_LED = new Map<SpeedEditor.Led, number>([
735 ["closeUp", 1 << 0],
736 ["cut", 1 << 1],
737 ["dissolve", 1 << 2],
738 ["smoothCut", 1 << 3],
739 ["transition", 1 << 4],
740 ["snap", 1 << 5],
741 ["cam7", 1 << 6],
742 ["cam8", 1 << 7],
743 ["cam9", 1 << 8],
744 ["liveOverwrite", 1 << 9],
745 ["cam4", 1 << 10],
746 ["cam5", 1 << 11],
747 ["cam6", 1 << 12],
748 ["videoOnly", 1 << 13],
749 ["cam1", 1 << 14],
750 ["cam2", 1 << 15],
751 ["cam3", 1 << 16],
752 ["audioOnly", 1 << 17],
753]);
754
755const JOG_LED_BIT_BY_LED = new Map<SpeedEditor.JogLed, number>([
756 ["jog", 1 << 0],
757 ["shuttle", 1 << 1],
758 ["scroll", 1 << 2],
759]);
760
761const JOG_MODE_CODE_BY_MODE = new Map<SpeedEditor.JogMode, number>([
762 ["relative", 2],
763 ["relative-normalized", 2],
764 ["absolute-continuous", 1],
765 ["absolute-deadzero", 3],
766]);
767
768const JOG_MODE_BY_CODE = new Map<number, SpeedEditor.JogMode>([
769 [0, "relative"],
770 [1, "absolute-continuous"],
771 [2, "relative"],
772 [3, "absolute-deadzero"],
773]);
774
775const DOUBLE_PRESS_WINDOW_MS = 100;
776const NORMALIZED_JOG_THRESHOLD = 5;
777const NORMALIZED_JOG_IDLE_RESET_MS = 1_000;
778
779function authenticateDevice(device: import("node-hid").HID) {
780 sendFeatureReport(device, [
781 0x06,
782 0x00,
783 0x00,
784 0x00,
785 0x00,
786 0x00,
787 0x00,
788 0x00,
789 0x00,
790 0x00,
791 ]);
792
793 const challengeReport = getFeatureReport(device, 6, 10);
794 assertFeatureStage(challengeReport, 0x00, "get_kbd_challenge");
795 const challenge = getUint64LE(challengeReport, 2);
796
797 sendFeatureReport(device, [
798 0x06,
799 0x01,
800 0x00,
801 0x00,
802 0x00,
803 0x00,
804 0x00,
805 0x00,
806 0x00,
807 0x00,
808 ]);
809
810 const responseReport = getFeatureReport(device, 6, 10);
811 assertFeatureStage(responseReport, 0x02, "get_kbd_response");
812
813 sendFeatureReport(device, [
814 0x06,
815 0x03,
816 ...toLittleEndianBytes(bmdKeyboardAuth(challenge), 8),
817 ]);
818
819 const statusReport = getFeatureReport(device, 6, 10);
820 assertFeatureStage(statusReport, 0x04, "get_kbd_status");
821
822 return getUint16LE(statusReport, 2);
823}
824
825function sendFeatureReport(device: import("node-hid").HID, values: number[]) {
826 const written = device.sendFeatureReport(values);
827 if (written <= 0) {
828 throw new Error("Failed to send Speed Editor feature report");
829 }
830}
831
832function getFeatureReport(
833 device: import("node-hid").HID,
834 reportId: number,
835 length: number,
836) {
837 return Uint8Array.from(device.getFeatureReport(reportId, length));
838}
839
840function assertFeatureStage(report: Uint8Array, stage: number, name: string) {
841 if (report[0] !== 0x06 || report[1] !== stage) {
842 throw new Error(`Failed authentication ${name}`);
843 }
844}
845
846function writeLedState(
847 device: import("node-hid").HID,
848 leds: Iterable<SpeedEditor.Led>,
849) {
850 let bitfield = 0;
851 for (const led of leds) {
852 bitfield |= LED_BIT_BY_LED.get(led) ?? 0;
853 }
854 const bytes = [0x02, ...toLittleEndianBytes(BigInt(bitfield >>> 0), 4)];
855 device.write(bytes);
856}
857
858function writeJogLedState(
859 device: import("node-hid").HID,
860 leds: Iterable<SpeedEditor.JogLed>,
861) {
862 let bitfield = 0;
863 for (const led of leds) {
864 bitfield |= JOG_LED_BIT_BY_LED.get(led) ?? 0;
865 }
866 device.write([0x04, bitfield]);
867}
868
869function writeJogMode(
870 device: import("node-hid").HID,
871 mode: SpeedEditor.JogMode,
872) {
873 const code = JOG_MODE_CODE_BY_MODE.get(mode);
874 if (code === undefined) {
875 throw new Error(`Unsupported jog mode: ${mode}`);
876 }
877 device.write([0x03, code, 0x00, 0x00, 0x00, 0x00, 0xff]);
878}
879
880function copyDeviceInfo(
881 device: import("node-hid").Device,
882): SpeedEditor.DeviceInfo {
883 return { ...device };
884}
885
886function formatDeviceLogLabel(device: SpeedEditor.DeviceInfo) {
887 const name = device.product ?? device.manufacturer ?? "Speed Editor";
888 const details = [
889 device.serialNumber && `serial=${device.serialNumber}`,
890 device.path && `path=${device.path}`,
891 ].filter(Boolean);
892
893 if (details.length === 0) {
894 return name;
895 }
896
897 return `${name} (${details.join(", ")})`;
898}
899
900function closeDeviceHandle(device: import("node-hid").HID) {
901 device.removeAllListeners("data");
902 device.removeAllListeners("error");
903 try {
904 device.close();
905 } catch {
906 // Ignore close races when the device disappears while reconnecting.
907 }
908}
909
910function getDeviceKey(device: SpeedEditor.DeviceInfo | null) {
911 if (!device) {
912 return null;
913 }
914
915 return device.path
916 ?? [
917 device.vendorId,
918 device.productId,
919 device.serialNumber ?? "",
920 device.interface,
921 device.release,
922 device.usagePage ?? "",
923 device.usage ?? "",
924 ].join(":");
925}
926
927function isRecoverableDeviceError(error: unknown) {
928 if (!(error instanceof Error)) {
929 return false;
930 }
931
932 const message = error.message.toLowerCase();
933 return [
934 "cannot open device",
935 "cannot access closed device",
936 "cannot write to closed device",
937 "cannot write to hid device",
938 "could not read data from device",
939 "could not get feature report from device",
940 "could not send feature report to device",
941 "unable to get device info",
942 "device not found",
943 "no such device",
944 ].some((pattern) => message.includes(pattern));
945}
946
947function rol8(value: bigint) {
948 return ((value << 56n) | (value >> 8n)) & UINT64_MASK;
949}
950
951function rol8n(value: bigint, count: bigint) {
952 let next = value;
953 for (let index = 0n; index < count; index += 1n) {
954 next = rol8(next);
955 }
956 return next;
957}
958
959function bmdKeyboardAuth(challenge: bigint) {
960 const index = Number(challenge & 0x7n);
961 let value = rol8n(challenge, BigInt(index));
962
963 if ((value & 0x1n) === BigInt((0x78 >> index) & 0x1)) {
964 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_EVEN_TABLE[index];
965 }
966
967 value = value ^ rol8(value);
968 return value ^ (rol8(value) & AUTH_MASK) ^ AUTH_ODD_TABLE[index];
969}
970
971function getUint16LE(bytes: Uint8Array, offset: number) {
972 return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
973}
974
975function getInt32LE(bytes: Uint8Array, offset: number) {
976 return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
977 .getInt32(offset, true);
978}
979
980function getUint64LE(bytes: Uint8Array, offset: number) {
981 let value = 0n;
982 for (let index = 0; index < 8; index += 1) {
983 value |= BigInt(bytes[offset + index] ?? 0) << (BigInt(index) * 8n);
984 }
985 return value;
986}
987
988function toLittleEndianBytes(value: bigint, width: number) {
989 const bytes: number[] = [];
990 for (let index = 0; index < width; index += 1) {
991 bytes.push(Number((value >> (BigInt(index) * 8n)) & 0xffn));
992 }
993 return bytes;
994}