1import { Events } from "@clo/lib/Events.ts";
2import type { Dispose } from "@clo/lib/ts.ts";
3
4// Node.js bindings for the Logitech MX Creative Keypad — the 3x3 LCD grid plus
5// the two screenless buttons below it. Talks the device's HID protocol directly
6// (no vendor SDK). Protocol cross-referenced from the Stream-Deck-style wire
7// format the hardware uses:
8// - input report 0x13: grid buttons (hidId = index + 1, int8 list from off 5)
9// - input report 0x11: back/forward (hidId 0x01a1/0x01a2, uint16 BE from off 3)
10// - output report 0x14: image data, 4095-byte packets with a positioned header
11// - output report 0x11: brightness (0x11 ff 0f 2b 00 <pct>)
12// - feature report 0x03: reset to logo
13const LOGITECH_VENDOR_ID = 0x046d;
14const KEYPAD_PRODUCT_ID = 0xc354;
15
16const KEY_SIZE = 118;
17// Each grid key writes a sub-rect of the panel framebuffer. Positions are
18// offset (23, 6) with a 158px pitch (118px key + 40px gap).
19const GRID_OFFSET = { x: 23, y: 6 };
20const GRID_PITCH = KEY_SIZE + 40;
21
22const NAME_BY_INDEX = [
23 "up-left",
24 "up",
25 "up-right",
26 "left",
27 "center",
28 "right",
29 "down-left",
30 "down",
31 "down-right",
32 "back",
33 "forward",
34] as const;
35
36const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[];
37const INDEX_BY_NAME = new Map<Keypad.Key, number>(
38 NAME_BY_INDEX.map((name, index) => [name, index]),
39);
40
41const KEY_POSITION = LCD_KEYS.map((_, index) => ({
42 x: GRID_OFFSET.x + (index % 3) * GRID_PITCH,
43 y: GRID_OFFSET.y + Math.floor(index / 3) * GRID_PITCH,
44}));
45
46const PANEL_SIZE = 480;
47
48/** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */
49export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION;
50/** Full panel pixel size (square). */
51export const PANEL_SIZE_PX = PANEL_SIZE;
52
53// Input hidId -> key name. Grid keys use hidId = index + 1; the two page buttons
54// report 16-bit ids.
55const NAME_BY_HID = new Map<number, Keypad.Key>(
56 LCD_KEYS.map((name, index) => [index + 1, name]),
57);
58NAME_BY_HID.set(0x01a1, "back");
59NAME_BY_HID.set(0x01a2, "forward");
60
61// Sent on connect so the back/forward buttons emit raw HID events.
62const INIT_WRITES = [0x01a1, 0x01a2].map((hidId) => {
63 const buffer = Buffer.alloc(20);
64 buffer.set([0x11, 0xff, 0x0b, 0x3b, (hidId >> 8) & 0xff, hidId & 0xff, 0x03]);
65 return buffer;
66});
67
68const IMAGE_REPORT_ID = 0x14;
69const MAX_PACKET_SIZE = 4095;
70const PACKET1_HEADER = 20;
71const PACKETN_HEADER = 5;
72
73const RECONNECT_INTERVAL_MS = 1000;
74
75export class Keypad extends Events<Keypad.EventMap> {
76 static keys = NAME_BY_INDEX;
77 static lcdKeys = LCD_KEYS;
78
79 #device: import("node-hid").HID | null = null;
80 #closed = false;
81 #ready = false;
82 #reconnectTimer: ReturnType<typeof setInterval> | null = null;
83 #images = new Map<Keypad.Key, Uint8Array>();
84 #shown = new Map<Keypad.Key, Uint8Array>();
85 #panel: Uint8Array | null = null;
86 #shownPanel: Uint8Array | null = null;
87 #gridDown = new Set<Keypad.Key>();
88 #pageDown = new Set<Keypad.Key>();
89
90 private constructor() {
91 super();
92 }
93
94 static async open() {
95 const keypad = new Keypad();
96 await keypad.#start();
97 return keypad;
98 }
99
100 get connected(): boolean {
101 return this.#ready;
102 }
103
104 onPress(key: Keypad.Key, listener: () => void): Dispose {
105 return this.on("keypress", (code) => {
106 if (key === code) listener();
107 });
108 }
109
110 /**
111 * Show a pre-encoded JPEG on a grid key. The caller owns encoding (see
112 * KeypadUI); identical buffers are deduped so unchanged keys never re-send.
113 */
114 setImage(key: Keypad.Key, image: Uint8Array) {
115 this.#images.set(key, image);
116 this.#panel = null; // a per-key image supersedes any full-panel image
117 if (this.#shown.get(key) === image) return; // already on screen — cached
118 if (this.#writeImage(key, image)) {
119 this.#shown.set(key, image);
120 this.#shownPanel = null;
121 }
122 }
123
124 /**
125 * Show one composed image across the whole 480x480 panel as a single
126 * image-write. The device repaints every key in one refresh — no per-key
127 * cascade. Identical buffers are deduped.
128 */
129 setPanel(image: Uint8Array) {
130 this.#panel = image;
131 this.#images.clear(); // a full-panel image supersedes per-key images
132 if (this.#shownPanel === image) return; // already on screen — cached
133 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, image)) {
134 this.#shownPanel = image;
135 this.#shown.clear();
136 }
137 }
138
139 /** Brightness as 0..1. */
140 setBrightness(level: number) {
141 const percentage = Math.max(
142 1,
143 Math.min(100, Math.round(Math.max(0, Math.min(1, level)) * 100)),
144 );
145 const command = Buffer.alloc(20);
146 command.set([0x11, 0xff, 0x0f, 0x2b, 0x00, percentage]);
147 this.#write(command);
148 }
149
150 /** Reset all screens to the startup logo. */
151 reset() {
152 this.#shown.clear();
153 this.#shownPanel = null;
154 const command = Buffer.alloc(32);
155 command.set([0x03, 0x02]);
156 try {
157 this.#device?.sendFeatureReport(command);
158 } catch {
159 // Ignore if the device vanished.
160 }
161 }
162
163 close() {
164 if (this.#closed) return;
165 this.#closed = true;
166 if (this.#reconnectTimer) clearInterval(this.#reconnectTimer);
167 this.#reconnectTimer = null;
168 this.#disconnect(false);
169 this.emit("close");
170 }
171
172 async #start() {
173 await this.#connect();
174 // The keypad pairs over Bluetooth too, where `usb` hotplug is silent — poll.
175 this.#reconnectTimer = setInterval(() => {
176 if (!this.#device && !this.#closed) void this.#connect();
177 }, RECONNECT_INTERVAL_MS);
178 this.#reconnectTimer.unref?.();
179 }
180
181 async #connect() {
182 if (this.#device || this.#closed) return;
183
184 const { devices, HID } = await import("node-hid");
185 const match = devices().find(
186 (device) =>
187 device.vendorId === LOGITECH_VENDOR_ID &&
188 device.productId === KEYPAD_PRODUCT_ID &&
189 Boolean(device.path),
190 );
191 if (!match?.path) return;
192
193 try {
194 const device = new HID(match.path);
195 this.#device = device;
196 device.on("data", (report) => {
197 if (this.#device === device) this.#handleReport(report);
198 });
199 device.on("error", () => {
200 if (this.#device === device) this.#handleDeviceError();
201 });
202
203 for (const write of INIT_WRITES) device.write(write);
204 this.#ready = true;
205 this.emit("connect");
206 this.#reapplyImages();
207 } catch {
208 this.#device = null;
209 // Retry on the next poll tick.
210 }
211 }
212
213 #handleReport(report: Buffer | number[]) {
214 const buffer = Buffer.isBuffer(report) ? report : Buffer.from(report);
215 const reportId = buffer[0];
216 const data = buffer.subarray(1);
217 if (data[2] === 0x2b) return; // ack to a drawing write
218
219 if (reportId === 0x13) this.#handleGridInput(data);
220 else if (reportId === 0x11) this.#handlePageInput(data);
221 }
222
223 #handleGridInput(data: Buffer) {
224 if (data[0] !== 0xff || data[1] !== 0x02 || data[2] !== 0x00 || data[4] !== 0x01) {
225 return;
226 }
227 const pressed = new Set<Keypad.Key>();
228 for (let i = 5; i < data.length; i += 1) {
229 const value = data.readInt8(i);
230 if (value === 0) break;
231 const key = NAME_BY_HID.get(value);
232 if (key) pressed.add(key);
233 }
234 this.#applyPressed(pressed, this.#gridDown);
235 }
236
237 #handlePageInput(data: Buffer) {
238 if (data[0] !== 0xff || data[1] !== 0x0b || data[2] !== 0x00) return;
239 const pressed = new Set<Keypad.Key>();
240 for (let i = 3; i + 1 < data.length; i += 2) {
241 const value = data.readUInt16BE(i);
242 if (value === 0) break;
243 const key = NAME_BY_HID.get(value);
244 if (key) pressed.add(key);
245 }
246 this.#applyPressed(pressed, this.#pageDown);
247 }
248
249 #applyPressed(pressed: Set<Keypad.Key>, downSet: Set<Keypad.Key>) {
250 for (const key of downSet) {
251 if (!pressed.has(key)) {
252 downSet.delete(key);
253 this.emit("keyup", key);
254 }
255 }
256 for (const key of pressed) {
257 if (!downSet.has(key)) {
258 downSet.add(key);
259 this.emit("keydown", key);
260 this.emit("keypress", key);
261 }
262 }
263 }
264
265 #writeImage(key: Keypad.Key, image: Uint8Array): boolean {
266 const index = INDEX_BY_NAME.get(key);
267 if (index === undefined || index >= LCD_KEYS.length) return false;
268 const position = KEY_POSITION[index];
269 return this.#writeRegion(position.x, position.y, KEY_SIZE, KEY_SIZE, image);
270 }
271
272 #writeRegion(
273 x: number,
274 y: number,
275 width: number,
276 height: number,
277 image: Uint8Array,
278 ): boolean {
279 if (!this.#device) return false;
280 try {
281 for (const packet of packetizeImage(x, y, width, height, image)) {
282 this.#device.write(packet);
283 }
284 return true;
285 } catch {
286 return false;
287 }
288 }
289
290 #reapplyImages() {
291 this.#shown.clear();
292 this.#shownPanel = null;
293 if (this.#panel) {
294 if (this.#writeRegion(0, 0, PANEL_SIZE, PANEL_SIZE, this.#panel)) {
295 this.#shownPanel = this.#panel;
296 }
297 return;
298 }
299 for (const [key, image] of this.#images) {
300 if (this.#writeImage(key, image)) this.#shown.set(key, image);
301 }
302 }
303
304 #write(buffer: Buffer) {
305 try {
306 this.#device?.write(buffer);
307 } catch {
308 // Ignore if the device vanished.
309 }
310 }
311
312 #handleDeviceError() {
313 this.#disconnect(true);
314 }
315
316 #disconnect(emitEvent: boolean) {
317 const device = this.#device;
318 this.#device = null;
319 this.#ready = false;
320 this.#shown.clear();
321 this.#shownPanel = null;
322 this.#gridDown.clear();
323 this.#pageDown.clear();
324 if (device) {
325 device.removeAllListeners("data");
326 device.removeAllListeners("error");
327 try {
328 device.close();
329 } catch {
330 // Ignore close races when the device disappears mid-reconnect.
331 }
332 }
333 if (emitEvent) this.emit("disconnect");
334 }
335}
336
337/** Split a JPEG into the keypad's positioned image-write packets. */
338function packetizeImage(
339 x: number,
340 y: number,
341 width: number,
342 height: number,
343 jpeg: Uint8Array,
344): Buffer[] {
345 const packets: Buffer[] = [];
346 const total = jpeg.length;
347
348 const first = Buffer.alloc(MAX_PACKET_SIZE);
349 const firstBytes = Math.min(total, MAX_PACKET_SIZE - PACKET1_HEADER);
350 first.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
351 first[4] = packetByte(1, true, firstBytes >= total);
352 first.writeUInt16BE(0x0100, 5);
353 first.writeUInt16BE(0x0100, 7);
354 first.writeUInt16BE(x, 9);
355 first.writeUInt16BE(y, 11);
356 first.writeUInt16BE(width, 13);
357 first.writeUInt16BE(height, 15);
358 first.writeUInt16BE(total, 18);
359 first.set(jpeg.subarray(0, firstBytes), PACKET1_HEADER);
360 packets.push(first);
361
362 let remaining = total - firstBytes;
363 let part = 2;
364 while (remaining > 0) {
365 const packet = Buffer.alloc(MAX_PACKET_SIZE);
366 const bytes = Math.min(remaining, MAX_PACKET_SIZE - PACKETN_HEADER);
367 const offset = total - remaining;
368 packet.set([IMAGE_REPORT_ID, 0xff, 0x02, 0x2b]);
369 packet[4] = packetByte(part, false, remaining - bytes === 0);
370 packet.set(jpeg.subarray(offset, offset + bytes), PACKETN_HEADER);
371 packets.push(packet);
372 remaining -= bytes;
373 part += 1;
374 }
375 return packets;
376}
377
378function packetByte(index: number, isFirst: boolean, isLast: boolean): number {
379 let value = index | 0b0010_0000;
380 if (isFirst) value |= 0b1000_0000;
381 if (isLast) value |= 0b0100_0000;
382 return value;
383}
384
385export declare namespace Keypad {
386 export type Key = typeof NAME_BY_INDEX[number];
387
388 export type EventMap = {
389 "connect": [];
390 "disconnect": [];
391 "close": [];
392 "error": [error: unknown];
393 "keydown": [key: Key];
394 "keyup": [key: Key];
395 "keypress": [key: Key];
396 };
397}