1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// The MX Creative Console Dialpad pairs over Bluetooth as a Logitech HID++
5// device. It does NOT need HID++ feature access: it streams a single 8-byte
6// input report (id 0x02) that decodes cleanly. Discovered by sniffing — see
7// examples/hid-sniff.ts.
8//
9// 02 buttons -- -- -- -- spin rotate
10// b1 b6 b7
11//
12// `rotate` (main dial) and `spin` (knob) are signed int8 deltas; `buttons` is a
13// bitmask. NOTE: until the OS-seize phase, macOS also consumes these reports
14// (the dial scrolls, the buttons act as mouse buttons).
15const LOGITECH_VENDOR_ID = 0x046d;
16const DIALPAD_PRODUCT_ID = 0xbc00;
17
18const REPORT_ID = 0x02;
19const BUTTON_BYTE = 1;
20const SPIN_BYTE = 6;
21const ROTATE_BYTE = 7;
22
23const buttonIds = ["circle", "triangle", "square", "cross"] as const;
24
25const BUTTON_BIT_BY_ID = new Map<Dialpad.Button, number>([
26 ["square", 0x08],
27 ["cross", 0x10],
28 ["circle", 0x20],
29 ["triangle", 0x40],
30]);
31
32const RECONNECT_INTERVAL_MS = 1000;
33
34/**
35 * Node.js bindings for the Logitech MX Creative Console Dialpad (Bluetooth).
36 */
37export class Dialpad extends Events<Dialpad.EventMap> {
38 static buttons = buttonIds;
39
40 #options: Required<Dialpad.Options>;
41 #device: import("node-hid").HID | null = null;
42 #closed = false;
43 #ready = false;
44 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
45 #activeButtons = new Set<Dialpad.Button>();
46 #lastButtonMask = 0;
47
48 private constructor(options: Dialpad.Options = {}) {
49 super();
50 this.#options = {
51 vendorId: options.vendorId ?? LOGITECH_VENDOR_ID,
52 productId: options.productId ?? DIALPAD_PRODUCT_ID,
53 path: options.path ?? null,
54 };
55 }
56
57 static async open(options: Dialpad.Options = {}) {
58 const dialpad = new Dialpad(options);
59 await dialpad.#start();
60 return dialpad;
61 }
62
63 get connected(): boolean {
64 return this.#ready;
65 }
66
67 onPress(button: Dialpad.Button, listener: () => void): Dispose {
68 return this.on("keypress", (code) => {
69 if (button === code) listener();
70 });
71 }
72
73 close() {
74 if (this.#closed) return;
75 this.#closed = true;
76 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
77 this.#reconnectTimer = null;
78 this.#disconnect(false);
79 this.emit("close");
80 }
81
82 async #start() {
83 await this.#connect();
84 // Bluetooth devices don't raise `usb` hotplug events, so poll instead.
85 this.#reconnectTimer = setInterval(() => {
86 if (!this.#device && !this.#closed) void this.#connect();
87 }, RECONNECT_INTERVAL_MS);
88 this.#reconnectTimer.unref?.();
89 }
90
91 async #connect() {
92 if (this.#device || this.#closed) return;
93
94 const { devices, HID } = await import("node-hid");
95 const match = devices().find(
96 (device) =>
97 device.vendorId === this.#options.vendorId
98 && device.productId === this.#options.productId
99 && (this.#options.path ? device.path === this.#options.path : true)
100 && Boolean(device.path),
101 );
102 if (!match?.path) return;
103
104 try {
105 const device = new HID(match.path);
106 this.#device = device;
107 device.on("data", (report) => {
108 if (this.#device === device) this.#handleReport(report);
109 });
110 device.on("error", (error) => {
111 if (this.#device === device) this.#handleDeviceError(error);
112 });
113 this.#ready = true;
114 this.emit("connect");
115 } catch {
116 this.#device = null;
117 // Will retry on the next poll tick.
118 }
119 }
120
121 #handleReport(report: Buffer | number[]) {
122 const bytes = Uint8Array.from(report);
123 if (bytes[0] !== REPORT_ID) return;
124
125 const rotate = toInt8(bytes[ROTATE_BYTE] ?? 0);
126 if (rotate !== 0) this.emit("rotate", rotate);
127
128 const spin = toInt8(bytes[SPIN_BYTE] ?? 0);
129 if (spin !== 0) this.emit("spin", spin);
130
131 const mask = bytes[BUTTON_BYTE] ?? 0;
132 if (mask !== this.#lastButtonMask) {
133 this.#lastButtonMask = mask;
134 this.#applyButtonState(mask);
135 }
136 }
137
138 #applyButtonState(mask: number) {
139 const next = new Set<Dialpad.Button>();
140 for (const [button, bit] of BUTTON_BIT_BY_ID) {
141 if (mask & bit) next.add(button);
142 }
143
144 for (const button of this.#activeButtons) {
145 if (!next.has(button)) this.emit("keyup", button);
146 }
147 for (const button of next) {
148 if (!this.#activeButtons.has(button)) {
149 this.emit("keydown", button);
150 this.emit("keypress", button);
151 }
152 }
153
154 this.#activeButtons = next;
155 this.emit("key", [...next]);
156 }
157
158 #handleDeviceError(_error: unknown) {
159 this.#disconnect(true);
160 }
161
162 #disconnect(emitEvent: boolean) {
163 const device = this.#device;
164 this.#device = null;
165 this.#ready = false;
166 this.#activeButtons.clear();
167 this.#lastButtonMask = 0;
168 if (device) {
169 device.removeAllListeners("data");
170 device.removeAllListeners("error");
171 try {
172 device.close();
173 } catch {
174 // Ignore close races when the device disappears mid-reconnect.
175 }
176 }
177 if (emitEvent) this.emit("disconnect");
178 }
179}
180
181function toInt8(byte: number): number {
182 return byte > 127 ? byte - 256 : byte;
183}
184
185export declare namespace Dialpad {
186 export type Button = typeof buttonIds[number];
187
188 export interface Options {
189 vendorId?: number;
190 productId?: number;
191 path?: string | null;
192 }
193
194 export type EventMap = {
195 "connect": [];
196 "disconnect": [];
197 "close": [];
198 "error": [error: unknown];
199 /** Main dial delta (signed, clockwise positive). */
200 "rotate": [delta: number];
201 /** Up/down knob delta (signed, up positive). */
202 "spin": [delta: number];
203 "key": [activeButtons: ReadonlyArray<Button>];
204 "keydown": [button: Button];
205 "keyup": [button: Button];
206 "keypress": [button: Button];
207 };
208}