| 1 | import { Events } from "@clo/lib/Events.ts"; |
| 2 | import 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 |
| 13 | const LOGITECH_VENDOR_ID = 0x046d; |
| 14 | const KEYPAD_PRODUCT_ID = 0xc354; |
| 15 | |
| 16 | const 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). |
| 19 | const GRID_OFFSET = { x: 23, y: 6 }; |
| 20 | const GRID_PITCH = KEY_SIZE + 40; |
| 21 | |
| 22 | const 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 | |
| 36 | const LCD_KEYS = NAME_BY_INDEX.slice(0, 9) as readonly Keypad.Key[]; |
| 37 | const INDEX_BY_NAME = new Map<Keypad.Key, number>( |
| 38 | NAME_BY_INDEX.map((name, index) => [name, index]), |
| 39 | ); |
| 40 | |
| 41 | const 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 | |
| 46 | const PANEL_SIZE = 480; |
| 47 | |
| 48 | /** Grid key (x,y) positions within the 480x480 panel framebuffer, row-major. */ |
| 49 | export const KEY_POSITIONS: ReadonlyArray<{ x: number; y: number }> = KEY_POSITION; |
| 50 | /** Full panel pixel size (square). */ |
| 51 | export 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. |
| 55 | const NAME_BY_HID = new Map<number, Keypad.Key>( |
| 56 | LCD_KEYS.map((name, index) => [index + 1, name]), |
| 57 | ); |
| 58 | NAME_BY_HID.set(0x01a1, "back"); |
| 59 | NAME_BY_HID.set(0x01a2, "forward"); |
| 60 | |
| 61 | // Sent on connect so the back/forward buttons emit raw HID events. |
| 62 | const 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 | |
| 68 | const IMAGE_REPORT_ID = 0x14; |
| 69 | const MAX_PACKET_SIZE = 4095; |
| 70 | const PACKET1_HEADER = 20; |
| 71 | const PACKETN_HEADER = 5; |
| 72 | |
| 73 | const RECONNECT_INTERVAL_MS = 1000; |
| 74 | |
| 75 | export 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. */ |
| 338 | function 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 | |
| 378 | function 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 | |
| 385 | export 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 | } |