diff --git a/README.md b/README.md index 0074b69..3af5fa1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ checklist. | Fantech | `@openmouse/protocol/fantech` | | Finalmouse | `@openmouse/protocol/finalmouse` | | G-Wolves | `@openmouse/protocol/gwolves` | -| GearHub (Attack Shark / Lingbao) | `@openmouse/protocol/gearhub` | +| GearHub (AJAZZ / Attack Shark / Lingbao) | `@openmouse/protocol/gearhub` | | Glorious | `@openmouse/protocol/glorious` | | Glorious classic (Model O/D/I) | `@openmouse/protocol/glorious-classic` | | HyperX | `@openmouse/protocol/hyperx` | @@ -86,6 +86,12 @@ by the official NinjaForce WebHID panel. They have automated transport and codec coverage, but are not marked as hardware-verified until tested on the corresponding Sora V2/V3 and TEN-family devices. +AJAZZ AJ179 PRO is supported by the GearHub driver, identified by device id +1851 rather than its shared receiver PID. USB, 2.4 GHz and Bluetooth settings +have been exercised on hardware. See [docs/ajazz-aj179-pro.md](docs/ajazz-aj179-pro.md) +for transport framing, verified writes and remaining limitations. This does +not claim support for other AJAZZ models or measured high-rate Bluetooth input. + The SteelSeries Rival 3 Gen 1 codec and driver are derived from the public rivalcfg project, corroborated against libratbag and OpenRGB. The device is write-only — only the firmware version can be read back — and no entry is diff --git a/captures/ajazz-aj179-pro.json b/captures/ajazz-aj179-pro.json new file mode 100644 index 0000000..8aeb3ac --- /dev/null +++ b/captures/ajazz-aj179-pro.json @@ -0,0 +1,20 @@ +{ + "model": "AJAZZ AJ179 PRO", + "date": "2026-10-05", + "os": "Windows 11", + "transport": "2.4 GHz 8K receiver", + "vendorId": 12625, + "productId": 16429, + "productName": "AJAZZ 2.4G 8K", + "usagePage": 65535, + "usage": 2, + "interfaceNumber": 2, + "reportDescriptor": "06ffff0902a10109021580257f75089540b102c0", + "replies": { + "usbVersion": "8f3b0700000500700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "firmware": "800303000000007f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "dpi": "d40002080000002b58022003e803b004dc0500000000000058022003e803b004dc05000000000000ff000000ff000000ffbd10e0ffff00ff00ffff00ffffffff", + "option0": "d30000000000002c0001020000000c01010000000000000003030407000000000000000000000000140000001400000000006464010000000000000000000000", + "dongleStatus": "01005501000102000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/docs/ajazz-aj179-pro.md b/docs/ajazz-aj179-pro.md new file mode 100644 index 0000000..e1059fe --- /dev/null +++ b/docs/ajazz-aj179-pro.md @@ -0,0 +1,115 @@ +# AJAZZ AJ179 PRO + +The AJ179 PRO uses the existing GearHub-V5 driver, not a separate AJAZZ +driver. GET_USB_VERSION device id **1851** identifies the model; the +shared VID/PID does not. The profile supplies its PAW3395 limits and button +layout. No application-specific UI or native service is required. + +## Evidence + +Hardware verification: Windows 11, firmware v3.03, 2026-10-05. Identity and +settings were exercised through the built WebHID driver and registry, using +a local HIDAPI adapter. The installed AJAZZ Driver (R) catalog independently +maps device id 1851 to AJ179 PRO / PAW3395. Analysis of its BLE backend +established the Bluetooth framing and battery request. No vendor binaries, +proprietary source, HID paths, or serial numbers are included. + +`captures/ajazz-aj179-pro.json` contains sanitized receiver replies used +as regression fixtures. The official AJAZZ driver catalog is at +. + +## Transports + +| Connection | VID:PID | Control collection | Reports | +| --- | --- | --- | --- | +| 2.4 GHz receiver | 3151:402D | FFFF:0002 | Unnumbered 64-byte feature | +| USB cable | 3151:4026 | FFFF:0002 | Unnumbered 64-byte feature | +| Bluetooth | 3151:402C | FF55:0202 | ID 6, 65-byte input/output payload | + +Receiver commands use the existing F6/05 target selection, F7 readiness, +checksummed mouse command, F7 readiness, FC notice-read, feature-read +sequence. USB skips the relay. All three paths resolve the same device id. +The charging dock has no configurable controls claimed by this integration. + +Bluetooth payloads are `[0x55, ...64-byte USB block]`; the report id is +supplied separately to WebHID. Replies arrive through `inputreport`, not +feature reports. A listener is attached before sending, commands are queued, +and unrelated report ids/envelopes/echoed command ids are ignored. Request +loopbacks are rejected rather than decoded as settings. SET packets have +no read-back exchange; a short delay separates them from subsequent GETs. + +Bluetooth battery uses a separate raw payload `[0x77, ...64 zero bytes]`, +without the 0x55 envelope or USB checksum. The reply begins with 0x77 and +the percentage; 0x88 indicates sleep. Values 0..100 are accepted; loopbacks +and invalid values are rejected. Battery read failure does not prevent +reading settings. Charging state remains **Unknown** on this model: no +reliable charging flag has been established, including on the dock. + +Bluetooth descriptor: +`0655ff0a0202a10185060902150026ff007508954181000902150026ff00750895419100c0`. + +## Settings and model-specific layout + +- DPI: 50..26000 in 50-DPI increments, including 1000; separate X/Y values, + active-stage selection, and stage indicator colors. +- GET_DPI reports eight-slot capacity. Trailing stages disabled on both axes + are omitted only from status; all eight slots and colors survive writes. +- Stored polling: 125/250/500/1000/2000/4000/8000 Hz on the receiver; USB + is capped at 1000 Hz. Stored Bluetooth polling is **not** a measurement + or guarantee of the BLE link's physical input rate. +- Low/High lift-off, debounce 0..10 ms, angle snapping, ripple correction, + and sleep. Sleep writes preserve the existing driver policy of setting + both link timers; status reads the timer for the connected wireless link. +- Button remapping reuses the shared six-button editor and nine mouse/DPI + actions. AJ179 has Forward in slot 3 and Back in slot 4, reversed from + the default GearHub layout. GET D0 returns a raw matrix; SET 50 changes + only one slot. Both commands use the existing Bit7 checksum. + +Stage-count editing, onboard profile switching, keyboard bindings, macros, +firmware updates, reset, pairing, and calibration are not implemented or +claimed. The vendor UI hides separate DPI axes and starts debounce at 2 ms; +successful readback of lower debounce and separate axes does not establish +their physical effect. + +## Hardware verification results + +| Check | Receiver | USB | Bluetooth | +| --- | --- | --- | --- | +| Settings write/readback | 49 cases | 46 cases | 49 cases | +| Side-button actions write/readback | 18 cases | 18 cases | 18 cases | +| Back/Forward physically reassigned to Middle Click | Passed | Not measured | Not measured | +| Physical DPI-button stage advance | Passed | Not measured | Not measured | +| Windows-delivered motion report rate | 500 / 1000 Hz passed | Not measured | Not measured | + +Settings cases cover all exposed polling values, enabled-stage value/color/ +selection, DPI boundaries, separate axes, lift-off, debounce, corrections, +and sleep. Button cases cover nine actions on each side button and preserve +every other matrix byte. Complete option payloads, all eight DPI slots/ +colors/active index, and the full key matrix were restored and compared. +These same settings persisted after removing charging power and switching +the mouse off for ten seconds, then reconnecting through Bluetooth. + +Physical side-button tests used user-assisted Windows button-state +observation, not device-specific Raw Input. The DPI button advanced stage +2 to 3 without changing the table; both stages had 1000 DPI, so this proves +selection rather than a measured sensitivity change. Foreground Raw Input +filtered by device VID/PID measured 500 Hz (4001 reports / 8 s) and 994.5 Hz +median at configured 1000 Hz (7947 reports / 8 s). Background measurements +were delivery-limited and are not evidence of a mouse rate fault. + +Actual sensor resolution, correction effects, lift-off height, LED appearance, +standby timing, receiver rates above 1000 Hz, and physical button/output +effects on USB/Bluetooth remain unmeasured. Inconsistent reads were observed +while other HID clients were open during initial experiments; concurrent +access is a plausible cause, not a proven root cause. Close other configurators +and wake the mouse before manual verification. + +## Automated verification + +Run `npm run check` in this repository. Follow `CONTRIBUTING.md` to install +the local package into OpenMouse without changing its manifest or lockfile, +then run the application's `npm run check`. Regression tests cover captured +status decoding, model quirks, shared-family behavior, framing, discovery, +write preservation, malformed/unrelated replies, listener cleanup, queue +recovery, and optional Bluetooth battery failures. Development-only native +observers and vendor reverse-engineering tools are not part of this package. diff --git a/src/drivers/gearhub/bluetooth.test.ts b/src/drivers/gearhub/bluetooth.test.ts new file mode 100644 index 0000000..228264e --- /dev/null +++ b/src/drivers/gearhub/bluetooth.test.ts @@ -0,0 +1,178 @@ +import { it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { GearHubHidClient } from "./hid.ts"; +import { createSupportedClient } from "../registry.ts"; +import { GEARHUB_HID_FILTERS } from "../vendors.ts"; +import { CMD } from "@openmouse/protocol/gearhub"; + +function fakeBluetooth(echo = false, battery = 86, batteryMarker = 0x77, silent = false) { + const capture = JSON.parse(readFileSync(new URL("../../../captures/ajazz-aj179-pro.json", import.meta.url), "utf8")); + const replies = new Map([ + [CMD.GET_USB_VERSION, Buffer.from(capture.replies.usbVersion, "hex")], + [CMD.GET_FIRMWARE, Buffer.from(capture.replies.firmware, "hex")], + [CMD.GET_DPI, Buffer.from(capture.replies.dpi, "hex")], + [CMD.GET_OPTIONPARAM0, Buffer.from(capture.replies.option0, "hex")], + [CMD.GET_KEYMATRIX, Buffer.from("0100f0000100f1000100f2000100f4000100f300140000000000000000000000000000000000000000000000000000000100f9010100f9ff0100f5010100f5ff", "hex")], + ]); + const listeners = new Set<(event: HIDInputReportEvent) => void>(); + replies.get(CMD.GET_OPTIONPARAM0)![40] = 45; // BLE timer differs from 2.4 GHz. + const sent: Uint8Array[] = []; + const device = { + vendorId: 0x3151, productId: 0x402c, productName: "pan1080xa3", opened: true, + collections: [{ usagePage: 0xff55, usage: 0x0202, children: [] }], + open: async () => {}, close: async () => {}, + addEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => listeners.add(fn), + removeEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => listeners.delete(fn), + sendFeatureReport: async () => { throw new Error("BLE has no feature reports"); }, + sendReport: async (id: number, data: Uint8Array) => { + if (silent) { sent.push(data.slice()); return; } + if (data[0] === 0x77) { + assert.equal(id, 6); assert.equal(data.length, 65); + assert.ok(data.slice(1).every(value => value === 0), "battery poll has neither USB envelope nor checksum"); + sent.push(data.slice()); + const response = echo ? data.slice() : new Uint8Array(65); + if (!echo) response.set([batteryMarker, battery, 3, 3]); + for (const listener of listeners) listener({ reportId: 6, data: new DataView(response.buffer) } as HIDInputReportEvent); + return; + } + assert.equal(id, 6); assert.equal(data.length, 65); assert.equal(data[0], 0x55); + assert.equal(data[8], 255 - (data.slice(1, 8).reduce((a, b) => a + b, 0) & 255)); + sent.push(data.slice()); + const block = data.slice(1); + if (block[0] === CMD.SET_DPI) { block[0] = CMD.GET_DPI; replies.set(CMD.GET_DPI, Buffer.from(block)); return; } + const reply = new Uint8Array(65); reply[0] = 0x55; + reply.set(echo ? block : replies.get(block[0])!, 1); + for (const listener of listeners) listener({ reportId: 6, data: new DataView(reply.buffer) } as HIDInputReportEvent); + }, + } as unknown as HIDDevice; + return { device, sent, listeners }; +} + +it("offers only the AJ179 Bluetooth vendor control interface", () => { + const { device } = fakeBluetooth(); + assert.ok(GEARHUB_HID_FILTERS.some(f => f.productId === 0x402c && f.usagePage === 0xff55 && f.usage === 0x0202)); + assert.ok(createSupportedClient(device) instanceof GearHubHidClient); + assert.equal(GearHubHidClient.isSupported({ ...device, collections: [{ usagePage: 1, usage: 2 }] } as HIDDevice), false); + assert.equal(GearHubHidClient.isSupported({ ...device, productId: 0x402d } as HIDDevice), false); +}); + +it("reads Bluetooth identity/settings and writes DPI without USB feature I/O", async () => { + const { device, listeners } = fakeBluetooth(); + const client = new GearHubHidClient(device); + const status = await client.readStatus(); + assert.equal(status.name, "AJAZZ AJ179 PRO"); + assert.equal(status.connectionDetail, "Bluetooth"); + assert.equal(status.batteryPercent, 86); + assert.equal(status.batteryState, "Unknown", "percentage alone does not establish charging state"); + assert.equal(status.dpi, 1000); + assert.equal(status.sleepTimeout, 45); + assert.equal(status.buttonMappings?.Back, "Back"); + assert.equal(status.buttonMappings?.Forward, "Forward"); + const original = await client.getDpi(); + await client.setDpi(1050); + const changed = await client.getDpi(); + assert.equal(changed.stages[original.activeIndex].x, 1050); + for (let i = 0; i < original.stages.length; i++) { + if (i !== original.activeIndex) assert.deepEqual(changed.stages[i], original.stages[i]); + else assert.equal(changed.stages[i].rgb, original.stages[i].rgb); + } + assert.equal(listeners.size, 0, "listeners released after every exchange"); +}); + +it("rejects BLE loopback instead of inventing settings from request bytes", async () => { + const { device, listeners } = fakeBluetooth(true); + await assert.rejects(new GearHubHidClient(device).getDeviceId(), /echoed/); + assert.equal(listeners.size, 0); +}); + +it("reads 0% and 100% Bluetooth battery, without rounding or dropping zero", async () => { + for (const percent of [0, 100]) { + const { device, listeners } = fakeBluetooth(false, percent); + assert.equal(await new GearHubHidClient(device).getBluetoothBattery(), percent); + assert.equal(listeners.size, 0); + } +}); + +it("rejects sleeping, invalid, and echoed Bluetooth battery packets", async () => { + for (const [echo, percent, marker] of [[true, 86, 0x77], [false, 255, 0x77], [false, 86, 0x88]] as const) { + const { device, listeners } = fakeBluetooth(echo, percent, marker); + await assert.rejects(new GearHubHidClient(device).getBluetoothBattery(), /echoed|invalid|sleeping/); + assert.equal(listeners.size, 0); + } +}); + +it("a battery failure does not prevent reading Bluetooth settings", async () => { + const { device } = fakeBluetooth(false, 255); + const status = await new GearHubHidClient(device).readStatus(); + assert.equal(status.batteryPercent, null); + assert.equal(status.dpi, 1000); +}); + +it("ignores unrelated and malformed BLE reports and respects DataView offsets", async () => { + const { device, listeners } = fakeBluetooth(false, 86, 0x77, true); + const client = new GearHubHidClient(device); + const pending = client.getDeviceId(); + await new Promise((resolve) => setImmediate(resolve)); + const emit = (reportId: number, bytes: Uint8Array, offset = 0) => { + const buffer = new Uint8Array(offset + bytes.length + 3); + buffer.set(bytes, offset); + for (const listener of listeners) { + listener({ reportId, data: new DataView(buffer.buffer, offset, bytes.length) } as HIDInputReportEvent); + } + }; + emit(6, new Uint8Array(64)); // Truncated. + emit(6, new Uint8Array(66)); // Oversized. + const reply = new Uint8Array(65); + reply.set([0x55, CMD.GET_USB_VERSION, 0x3b, 0x07]); + emit(5, reply); // Wrong report id. + emit(6, new Uint8Array(65).fill(0x77)); // Battery, not a command reply. + const stale = reply.slice(); + stale[1] = CMD.GET_FIRMWARE; + emit(6, stale); // Previous command, not identity. + assert.equal(listeners.size, 1); + emit(6, reply, 7); + assert.equal(await pending, 1851); + assert.equal(listeners.size, 0); +}); + +it("releases the BLE listener after a timeout and allows the next queued command", async () => { + const { device, listeners } = fakeBluetooth(false, 86, 0x77, true); + const client = new GearHubHidClient(device); + await assert.rejects(client.getDeviceId(), /did not answer/); + assert.equal(listeners.size, 0); + const pending = client.getDeviceId(); + await new Promise((resolve) => setImmediate(resolve)); + const reply = new Uint8Array(65); + reply.set([0x55, CMD.GET_USB_VERSION, 0x3b, 0x07]); + for (const listener of listeners) { + listener({ reportId: 6, data: new DataView(reply.buffer) } as HIDInputReportEvent); + } + assert.equal(await pending, 1851); + assert.equal(listeners.size, 0); +}); + +it("releases BLE listeners on send failure and opens the device before a write", async () => { + const { device, listeners } = fakeBluetooth(); + const client = new GearHubHidClient(device); + device.sendReport = async () => { throw new Error("send failed"); }; + await assert.rejects(client.getDeviceId(), /send failed/); + assert.equal(listeners.size, 0); + await assert.rejects(client.getBluetoothBattery(), /send failed/); + assert.equal(listeners.size, 0); + Object.defineProperty(device, "opened", { value: false, configurable: true }); + let opened = false; + device.open = async () => { opened = true; }; + device.sendReport = async () => { assert.ok(opened); }; + await client.setButtonMapping("Left", "Middle Click"); +}); + +it("serializes concurrent Bluetooth reads", async () => { + const { device, listeners, sent } = fakeBluetooth(); + const client = new GearHubHidClient(device); + assert.deepEqual(await Promise.all([client.getDeviceId(), client.getFirmwareVersion(), client.getBluetoothBattery()]), + [1851, 0x0303, 86]); + assert.deepEqual(sent.map((packet) => packet[0] === 0x77 ? 0x77 : packet[1]), + [CMD.GET_USB_VERSION, CMD.GET_FIRMWARE, 0x77]); + assert.equal(listeners.size, 0); +}); diff --git a/src/drivers/gearhub/hid.test.ts b/src/drivers/gearhub/hid.test.ts index 382902b..245c24a 100644 --- a/src/drivers/gearhub/hid.test.ts +++ b/src/drivers/gearhub/hid.test.ts @@ -1,5 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { CMD, OPT0_DEBOUNCE, @@ -121,6 +122,10 @@ function fakeReceiver(options: { last = out; } else { pending = replies[data[0]] ?? null; + if (device.productId === M5_PRO_WIRED.productId) { + last = new Uint8Array(64); + if (pending) last.set(pending); + } } }, receiveFeatureReport: async () => new DataView(last.buffer.slice(0)), @@ -170,6 +175,110 @@ function dpiReply(xs: number[], activeIndex: number, rgb: number[] = []) { } describe("GearHubHidClient", () => { + it("identifies AJ179 PRO and decodes captured settings without disabled DPI slots", async () => { + const capture = JSON.parse(readFileSync(new URL("../../../captures/ajazz-aj179-pro.json", import.meta.url), "utf8")); + const bytes = (key: string) => [...Buffer.from(capture.replies[key], "hex")]; + const { device, sent } = fakeReceiver({ deviceId: 1851, mouseBattery: 85, replies: { + [CMD.GET_USB_VERSION]: bytes("usbVersion"), + [CMD.GET_FIRMWARE]: bytes("firmware"), + [CMD.GET_DPI]: bytes("dpi"), + [CMD.GET_OPTIONPARAM0]: bytes("option0"), + [CMD.GET_KEYMATRIX]: keyMatrixReply({ 3: [1, 0, 0xf4, 0], 4: [1, 0, 0xf3, 0] }), + } }); + const client = new GearHubHidClient(device); + const status = await client.readStatus(); + assert.equal(status.brand, "AJAZZ"); + assert.equal(status.name, "AJAZZ AJ179 PRO"); + assert.equal(status.dpi, 1000); + assert.deepEqual(status.dpiStages, [600, 800, 1000, 1200, 1500]); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.batteryPercent, 85); + assert.equal(status.batteryState, "Unknown", "percentage does not establish charging state"); + assert.equal(status.debounceMs, 2); + assert.equal(status.sleepTimeout, 20); + assert.equal(status.liftOffDistance, "High"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); + assert.deepEqual(status.firmware, ["v3.03", "PixArt PAW3395"]); + assert.deepEqual(status.buttonMappings, { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Back", Forward: "Forward", DPI: "DPI Loop" }); + assert.equal(sent.some((report) => report[0] === CMD.GET_KEYMATRIX), true); + await client.setButtonMapping("Back", "Middle Click"); + const mapping = sent.filter(report => report[0] === CMD.SET_KEYMATRIX).at(-1)!; + assert.equal(mapping[2], 4, "AJ179 Back is slot 4, not slot 3"); + assert.deepEqual([...mapping.slice(8, 12)], [1, 0, 0xf2, 0]); + assert.equal(mapping[7], 255 - CMD.SET_KEYMATRIX - 4); + await client.setButtonMapping("Forward", "Back"); + assert.equal(sent.filter(report => report[0] === CMD.SET_KEYMATRIX).at(-1)![2], 3); + assert.equal(client.getDpiOptions().at(-1), 26000); + assert.equal(client.getDpiOptions()[0], 50); + assert.ok(client.getDpiOptions().includes(1000), "typed 1000 DPI must not snap to 800"); + assert.ok(client.getDpiOptions().includes(1050), "advertise every supported 50 DPI step"); + assert.equal(client.getDpiOptions().length, 520); + const beforeRejected = sent.length; + await assert.rejects(client.setDpiStageValue(2, 0), /DPI must be/); + await assert.rejects(client.setDpiStageValue(2, 26050), /DPI must be/); + await assert.rejects(client.setDpiStageValue(2, 1051), /DPI must be/); + assert.equal(sent.length, beforeRejected, "unsupported writes issue no HID commands"); + // UI trimming must not change the capacity or disabled slots on writes. + await client.setDpiStageValue(2, 1050); + const write = sent.find((report) => report[0] === CMD.SET_DPI)!; + assert.equal(write[3], 8); + assert.equal(write[12] | (write[13] << 8), 1050); + assert.deepEqual([...write.subarray(18, 24)], bytes("dpi").slice(18, 24)); + assert.deepEqual([...write.subarray(40)], bytes("dpi").slice(40)); + }); + it("rejects stale identity packets rather than showing fabricated button mappings", async () => { + const client = new GearHubHidClient(fakeReceiver({ replies: { [CMD.GET_KEYMATRIX]: usbVersionReply(1851) } }).device); + await assert.rejects(client.getKeyMatrix(), /stale command/); + }); + + it("uses the same AJ179 profile and button layout by USB cable", async () => { + const capture = JSON.parse(readFileSync(new URL("../../../captures/ajazz-aj179-pro.json", import.meta.url), "utf8")); + const bytes = (key: string) => [...Buffer.from(capture.replies[key], "hex")]; + const { device, sent } = fakeReceiver({ ids: M5_PRO_WIRED, deviceId: 1851, replies: { + [CMD.GET_FIRMWARE]: bytes("firmware"), + [CMD.GET_DPI]: bytes("dpi"), + [CMD.GET_OPTIONPARAM0]: bytes("option0"), + [CMD.GET_KEYMATRIX]: keyMatrixReply({ 3: [1, 0, 0xf4, 0], 4: [1, 0, 0xf3, 0] }), + } }); + const client = new GearHubHidClient(device); + const status = await client.readStatus(); + assert.equal(status.name, "AJAZZ AJ179 PRO"); + assert.equal(status.connectionDetail, "USB"); + assert.equal(status.batteryPercent, null); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.supportedPollingRates.at(-1), 1000); + assert.equal(status.dpiStages.length, 5); + assert.equal(status.buttonMappings?.Back, "Back"); + assert.equal(status.buttonMappings?.Forward, "Forward"); + client.currentProfile = 2; + await client.setButtonMapping("Back", "Middle Click"); + const mapping = sent.find((report) => report[0] === CMD.SET_KEYMATRIX)!; + assert.equal(mapping[1], 2); + assert.equal(mapping[2], 4); + assert.equal(sent.some((report) => [0xf6, 0xf7, 0xfc].includes(report[0])), false); + await assert.rejects(client.setPollingRate(2000), /Unsupported rate/); + }); + + it("derives typed DPI options and validation from sibling model limits", async () => { + for (const [deviceId, maxDpi] of [[2285, 26000], [1893, 42000]]) { + const { device, sent } = fakeReceiver({ ids: M5_PRO_WIRED, deviceId, replies: { + [CMD.GET_DPI]: dpiReply([800, 1000], 1), + [CMD.GET_OPTIONPARAM0]: opt0Reply(), + [CMD.GET_KEYMATRIX]: keyMatrixReply(), + } }); + const client = new GearHubHidClient(device); + await client.readStatus(); + assert.ok(client.getDpiOptions().includes(1000)); + assert.equal(client.getDpiOptions().at(-1), maxDpi); + const beforeRejected = sent.length; + await assert.rejects(client.setDpiForStage(maxDpi + 50, 1000, 1), /DPI must be/); + await assert.rejects(client.setDpiForStage(1000, NaN, 1), /DPI must be/); + assert.equal(sent.length, beforeRejected); + await client.setDpiStageValue(1, 1000); + assert.equal(sent.at(-1)![0], CMD.SET_DPI); + } + }); it("stamps the Bit7 checksum the hardware echoed back", () => { // Checksums observed live in the device's own replies. assert.equal(encodeCommand([CMD.GET_FIRMWARE])[7], 0x7f); diff --git a/src/drivers/gearhub/hid.ts b/src/drivers/gearhub/hid.ts index c83343b..a1f6e90 100644 --- a/src/drivers/gearhub/hid.ts +++ b/src/drivers/gearhub/hid.ts @@ -8,6 +8,14 @@ import { DPI_Y_OFFSET, encodeButtonAction, encodeCommand, + gearHubBluetoothPacket, + GEARHUB_BLUETOOTH_BATTERY, + GEARHUB_BLUETOOTH_ENVELOPE, + GEARHUB_BLUETOOTH_REPORT_ID, + GEARHUB_BLUETOOTH_REPORT_SIZE, + GEARHUB_BLUETOOTH_SLEEPING, + GEARHUB_BLUETOOTH_USAGE, + GEARHUB_BLUETOOTH_USAGE_PAGE, GEARHUB_BUTTON_ACTIONS, GEARHUB_BUTTONS, GEARHUB_DEBOUNCE_MAX_MS, @@ -39,13 +47,13 @@ import { /** * GearHub-V5 WebHID driver (VID 0x3151). Owns the 2.4 GHz relay handshake and - * the feature-report I/O; packet shapes and the device catalog live in the - * `@openmouse/protocol/gearhub` codec. + * feature-report and Bluetooth input/output I/O. Packet shapes and the device + * catalog live in the `@openmouse/protocol/gearhub` codec. * * One driver claims the shared receiver VID:PID and then identifies the model - * from the GET_USB_VERSION device id: 2285 = Lingbao M5 Pro, 1893 = Attack - * Shark R2, anything else = the M5 Pro fallback. The product id only settles - * transport. + * from the GET_USB_VERSION device id and the codec's profile catalog. + * Unrecognized ids use a generic GearHub-V5 fallback. The product id only + * settles transport. * * ── Transport ──────────────────────────────────────────────────────────── * USB interface 2, vendor usage page 0xFFFF, usage 0x02, one unnumbered @@ -75,6 +83,7 @@ import { * * Plugged in by cable the mouse enumerates as PID 0x4026 and skips the relay * entirely: send the checksummed command, read the reply straight back. + * Bluetooth uses report-6 input/output framing instead of feature reports. */ export interface GearHubDongleStatus { @@ -127,17 +136,19 @@ export class GearHubHidClient { static isSupported(device: HIDDevice): boolean { if (device.vendorId !== GEARHUB_VENDOR_ID) return false; if (!GEARHUB_PRODUCTS.has(device.productId)) return false; + const bluetooth = GEARHUB_PRODUCTS.get(device.productId)?.transport === "bluetooth"; const hasControl = (collections: readonly HIDCollectionInfo[]): boolean => collections.some( (collection) => - (collection.usagePage === 0xffff && collection.usage === 0x02) || + (collection.usagePage === (bluetooth ? GEARHUB_BLUETOOTH_USAGE_PAGE : 0xffff) && + collection.usage === (bluetooth ? GEARHUB_BLUETOOTH_USAGE : 0x02)) || hasControl(collection.children ?? []), ); return hasControl(device.collections); } - /** Whether reads go through the 2.4 GHz relay or straight to the device. */ - get transport(): "dongle" | "direct" { + /** Product-specific link: receiver relay, direct USB, or Bluetooth reports. */ + get transport(): "dongle" | "direct" | "bluetooth" { return this.transportEntry?.transport ?? "direct"; } @@ -148,18 +159,14 @@ export class GearHubHidClient { return GEARHUB_RATES.filter((hz) => hz <= ceiling); } - /** - * DPI stops worth offering, capped at the resolved model's sensor ceiling. - * These sensors step in 50 DPI increments; the UI offers the round values. - * Before the first readStatus() this uses the fallback (M5 Pro) ceiling. - */ + /** Every writable DPI step, not just a shortlist of presets. The app uses + * this list to validate and round typed stage values as well as sliders. */ getDpiOptions(): number[] { - const { maxDpi } = this.resolvedProfile ?? GEARHUB_FALLBACK_PROFILE; - return [ - 400, 800, 1200, 1600, 2000, 2400, 3200, 4000, 4800, 5600, 6400, 8000, - 10000, 12000, 16000, 20000, 26000, - 28000, 30000, 32000, 36000, 40000, 42000, - ].filter((dpi) => dpi <= maxDpi); + const { minDpi, maxDpi, dpiStep } = this.resolvedProfile ?? GEARHUB_FALLBACK_PROFILE; + return Array.from( + { length: Math.floor((maxDpi - minDpi) / dpiStep) + 1 }, + (_, index) => minDpi + index * dpiStep, + ); } async open(): Promise { @@ -253,6 +260,10 @@ export class GearHubHidClient { await this.open(); const encoded = encodeCommand(bytes); + if (this.transport === "bluetooth") { + return this.exchangeBluetooth(encoded, bytes[0], echoes); + } + if (this.transport === "direct") { await this.rawSend(encoded); await delay(10); @@ -272,6 +283,78 @@ export class GearHubHidClient { return this.verifyReply(await this.rawRead(), bytes[0], echoes); } + /** Bluetooth replies are input reports. Attach before sending and release + * the listener on success, invalid response, timeout, or send failure. + * Callers own the command queue; null means an unrelated input report. */ + private requestBluetooth( + request: Uint8Array, + decode: (packet: Uint8Array) => T | null, + ): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", onReport); + }; + const onReport = (event: HIDInputReportEvent) => { + if (event.reportId !== GEARHUB_BLUETOOTH_REPORT_ID) return; + const packet = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (packet.length !== GEARHUB_BLUETOOTH_REPORT_SIZE) return; + try { + const result = decode(packet); + if (result === null) return; + cleanup(); + resolve(result); + } catch (error) { + cleanup(); + reject(error); + } + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error("GearHub Bluetooth did not answer. Move the mouse and retry.")); + }, 2000); + this.device.addEventListener("inputreport", onReport); + this.device.sendReport(GEARHUB_BLUETOOTH_REPORT_ID, request).catch((error) => { + cleanup(); + reject(error); + }); + }); + } + + private exchangeBluetooth(encoded: Uint8Array, command: number, echoes: boolean): Promise { + return this.requestBluetooth(gearHubBluetoothPacket(encoded), (packet) => { + if (packet[0] !== GEARHUB_BLUETOOTH_ENVELOPE) return null; + const reply = packet.slice(1); + if (echoes && reply[0] !== command) return null; + if (reply.every((value, i) => value === (encoded[i] ?? 0))) { + throw new Error("Bluetooth command was echoed without device data."); + } + return this.verifyReply(reply, command, echoes); + }); + } + + /** BLE link status is a separate envelope, not a USB command: [0x77]. + * The vendor BLE reader reads raw byte 2 as battery; WebHID + * strips report id 6, so it is payload byte 1. 0x88 means sleeping. + * No charging-state field has been established in this report. */ + async getBluetoothBattery(): Promise { + return this.enqueue(async () => { + if (this.transport !== "bluetooth") throw new Error("Bluetooth battery requires a Bluetooth connection."); + await this.open(); + const request = new Uint8Array(GEARHUB_BLUETOOTH_REPORT_SIZE); + request[0] = GEARHUB_BLUETOOTH_BATTERY; + return this.requestBluetooth(request, (packet) => { + if (packet[0] !== GEARHUB_BLUETOOTH_BATTERY && packet[0] !== GEARHUB_BLUETOOTH_SLEEPING) return null; + if (packet[0] === GEARHUB_BLUETOOTH_SLEEPING) throw new Error("GearHub Bluetooth is sleeping."); + if (packet.every((value, i) => value === request[i])) { + throw new Error("Bluetooth battery request was echoed without data."); + } + if (packet[1] > 100) throw new Error("GearHub Bluetooth returned an invalid battery percentage."); + return packet[1]; + }); + }); + } + private verifyReply(resp: Uint8Array, cmd: number, echoes: boolean): Uint8Array { if (resp.length < GEARHUB_REPORT_SIZE) { throw new Error( @@ -459,22 +542,33 @@ export class GearHubHidClient { * once — the relay is more likely to miss its ready window under load. */ async getKeyMatrix(profile = 0): Promise { + const read = async () => { + const reply = await this.command([CMD.GET_KEYMATRIX, profile], { echoes: false }); + // A lost relay response may leave an earlier echoed GET packet buffered. + // Matrix action types are small values, never a command id (>= 0x80). + if (reply[0] >= 0x80) throw new Error("GearHub returned a stale command instead of button mappings."); + return reply; + }; try { - return await this.command([CMD.GET_KEYMATRIX, profile], { echoes: false }); + return await read(); } catch { - return this.command([CMD.GET_KEYMATRIX, profile], { echoes: false }); + return read(); } } private buttonMappingsFrom(reply: Uint8Array): Record { const out: Record = {}; - for (const { name, slot } of GEARHUB_BUTTONS) { + for (const { name, slot } of this.buttons()) { const o = slot * 4; out[name] = decodeButtonAction([reply[o], reply[o + 1], reply[o + 2], reply[o + 3]]); } return out; } + private buttons() { + return this.resolvedProfile?.buttons ?? GEARHUB_BUTTONS; + } + /** Every action `setButtonMapping` accepts, in display order. */ getButtonOptions(): string[] { return GEARHUB_BUTTON_ACTIONS.map(([label]) => label); @@ -482,13 +576,13 @@ export class GearHubHidClient { /** Reassign one physical button. `button` is a `GEARHUB_BUTTONS` name. */ async setButtonMapping(button: string, action: string): Promise { - const target = GEARHUB_BUTTONS.find((b) => b.name === button); + const target = this.buttons().find((b) => b.name === button); if (!target) throw new Error(`Unknown button "${button}".`); const value = encodeButtonAction(action); if (!value) throw new Error(`Unsupported button action "${action}".`); const cmd = new Uint8Array(GEARHUB_REPORT_SIZE); cmd[0] = CMD.SET_KEYMATRIX; - cmd[1] = 0; // profile + cmd[1] = this.currentProfile; cmd[2] = target.slot; cmd[8] = value[0]; cmd[9] = value[1]; @@ -503,6 +597,12 @@ export class GearHubHidClient { * and echoed unchanged rather than zeroed. */ async setDpiForStage(x: number, y: number, index: number): Promise { + if (this.resolvedProfile) { + const { minDpi, maxDpi, dpiStep } = this.resolvedProfile; + if (![x, y].every((value) => Number.isInteger(value) && value >= minDpi && value <= maxDpi && (value - minDpi) % dpiStep === 0)) { + throw new Error(`GearHub DPI must be ${minDpi}..${maxDpi} in ${dpiStep} DPI steps.`); + } + } const { stages, activeIndex } = await this.getDpi(); const target = index >= 0 && index < stages.length ? index : activeIndex; stages[target] = { ...stages[target], x, y }; @@ -573,7 +673,13 @@ export class GearHubHidClient { } private async writeThrough(cmd: Uint8Array): Promise { + await this.open(); const encoded = encodeCommand([...cmd]); + if (this.transport === "bluetooth") { + await this.device.sendReport(GEARHUB_BLUETOOTH_REPORT_ID, gearHubBluetoothPacket(encoded)); + await delay(60); + return; + } if (this.transport === "direct") { await this.rawSend(encoded); await delay(10); @@ -598,6 +704,12 @@ export class GearHubHidClient { let connectionType: "Wired" | "Wireless" = "Wired"; let connectionDetail = "USB"; + if (this.transport === "bluetooth") { + connectionType = "Wireless"; + connectionDetail = "Bluetooth"; + try { battery = await this.getBluetoothBattery(); } catch { /* Optional: settings remain usable without battery. */ } + } + if (this.transport === "dongle") { connectionType = "Wireless"; connectionDetail = "2.4 GHz"; @@ -613,7 +725,7 @@ export class GearHubHidClient { // Identify the model before the first data read. GearHub keys its model // table off this id; the receiver's shared VID:PID cannot. A failure here // (older firmware, a sibling that does not answer 0x8F) is non-fatal — we - // fall back to the M5 Pro profile, which is what this driver always was. + // fall back to the generic GearHub-V5 profile. let deviceId: number | null = null; try { deviceId = await this.getDeviceId(); @@ -627,6 +739,13 @@ export class GearHubHidClient { // The DPI read has to work: it carries the values the UI exists to show, // and it is the cheapest proof the whole relay + checksum path is right. const dpi = await this.getDpi(); + // AJ179 PRO reports capacity (8), including trailing disabled zero slots. + // Trim only the UI view; getDpi()/writes retain the complete wire table. + if (profile.dpiStageCountIsCapacity) { + while (dpi.stages.length > 1 && dpi.stages.at(-1)?.x === 0 && dpi.stages.at(-1)?.y === 0) { + dpi.stages.pop(); + } + } const active = dpi.stages[dpi.activeIndex] ?? dpi.stages[0]; // Report rate and lift-off both live in the OPTIONPARAM0 block — one read @@ -634,7 +753,7 @@ export class GearHubHidClient { // unreported rather than sinking the whole status. const [opt0Result, keyMatrixResult, firmwareResult] = await Promise.allSettled([ this.getOptionParam0(), - this.getKeyMatrix(0), + this.getKeyMatrix(this.currentProfile), this.getFirmwareVersion(), ]); let pollingRateHz = 1000; @@ -650,7 +769,8 @@ export class GearHubHidClient { debounceMs = opt0[OPT0_DEBOUNCE]; angleSnapping = opt0[OPT0_STRAIGHT_CORRECTION] !== 0; rippleControl = (opt0[OPT0_FLAGS] & OPT0_FLAG_RIPPLE) !== 0; - sleepTimeout = opt0[OPT0_SLEEP_24G] | (opt0[OPT0_SLEEP_24G + 1] << 8); + const sleepOffset = this.transport === "bluetooth" ? OPT0_SLEEP_BT : OPT0_SLEEP_24G; + sleepTimeout = opt0[sleepOffset] | (opt0[sleepOffset + 1] << 8); const levels = this.liftOffLevels(); if (levels.length > 0) { liftOffDistance = levels[opt0[OPT0_SILENT_HEIGHT]] ?? levels[0]; @@ -686,7 +806,7 @@ export class GearHubHidClient { }, }, batteryPercent: battery, - batteryState: battery === null ? "Unknown" : "Discharging", + batteryState: battery === null || profile.batteryPercentageOnly ? "Unknown" : "Discharging", dpi: active.x, dpiY: active.y, supportsSeparateDpiAxes: true, diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index c078e81..af65324 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -153,7 +153,7 @@ export interface AtkReceiverInfo { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index 6924f47..de950a9 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -23,7 +23,7 @@ const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x09, 0x0e, 0x0f, 0x10, 0x11, 0x2 const USAGE_PAGES = [0x01, 0x0a, 0x0c, 0x8c, 0xFF07, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc0, 0xffc1, 0xffc2, 0xffff]; // Usage 4 is the Corsair config collection; 0x61 is VIA raw HID; 0xc7 is // Ryunix telemetry; 0x0e is Rapoo's 0xBA configuration channel. -const USAGES = [0, 1, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; +const USAGES = [0, 1, 0x0202, 0x0212, 2, 4, 0x0e, 0x10, 0x61, 0xc7]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 10546e6..17c9faa 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -9,7 +9,12 @@ import { MICROSOFT_PRODUCT_CLASSIC, MICROSOFT_PRODUCT_PRO, MICROSOFT_VENDOR_ID, import { INCOTT_PRODUCT_IDS, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../incott/index.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { VAXEE_PRODUCT_IDS, VAXEE_USAGE, VAXEE_USAGE_PAGE, VAXEE_VENDOR_ID } from "@openmouse/protocol/vaxee"; -import { GEARHUB_PRODUCTS, GEARHUB_VENDOR_ID } from "@openmouse/protocol/gearhub"; +import { + GEARHUB_BLUETOOTH_USAGE, + GEARHUB_BLUETOOTH_USAGE_PAGE, + GEARHUB_PRODUCTS, + GEARHUB_VENDOR_ID, +} from "@openmouse/protocol/gearhub"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; import { LAMZU_INCA_PRODUCTS, LAMZU_INCA_VENDOR_ID } from "@openmouse/protocol/lamzu"; import { @@ -680,8 +685,13 @@ export const LAMZU_INCA_HID_FILTERS: HIDDeviceFilter[] = [...LAMZU_INCA_PRODUCTS * keyboards and mice, so these are requested per product id rather than * vendor-wide. */ -export const GEARHUB_HID_FILTERS: HIDDeviceFilter[] = [...GEARHUB_PRODUCTS.keys()].map( - (productId) => ({ vendorId: GEARHUB_VENDOR_ID, productId, usagePage: 0xffff, usage: 0x02 }), +export const GEARHUB_HID_FILTERS: HIDDeviceFilter[] = [...GEARHUB_PRODUCTS.entries()].map( + ([productId, profile]) => ({ + vendorId: GEARHUB_VENDOR_ID, + productId, + usagePage: profile.transport === "bluetooth" ? GEARHUB_BLUETOOTH_USAGE_PAGE : 0xffff, + usage: profile.transport === "bluetooth" ? GEARHUB_BLUETOOTH_USAGE : 0x02, + }), ); // Corsair NXP-family mice answer on the interface whose collection is usage diff --git a/src/gearhub/index.test.ts b/src/gearhub/index.test.ts new file mode 100644 index 0000000..b9a3721 --- /dev/null +++ b/src/gearhub/index.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { + AJAZZ_AJ179_PRO_PROFILE, + CMD, + encodeCommand, + gearHubBluetoothPacket, + gearHubProfileFor, + GEARHUB_BUTTONS, +} from "./index.ts"; + +it("wraps checksummed Bluetooth commands without adding the WebHID report id", () => { + const encoded = encodeCommand([CMD.GET_DPI, 0]); + const original = encoded.slice(); + const packet = gearHubBluetoothPacket(encoded); + assert.equal(packet.length, 65); + assert.equal(packet[0], 0x55); + assert.deepEqual(packet.slice(1, 1 + encoded.length), encoded); + assert.equal(packet[8], 0x2b); + assert.ok(packet.slice(1 + encoded.length).every((byte) => byte === 0)); + assert.deepEqual(encoded, original, "caller-owned command is not mutated"); + const full = new Uint8Array(64).fill(0xab); + assert.deepEqual(gearHubBluetoothPacket(full).slice(1), full); + assert.throws(() => gearHubBluetoothPacket(new Uint8Array(65)), /exceeds 64/); +}); + +it("keeps AJ179 model quirks in its profile without changing sibling button layouts", () => { + assert.equal(gearHubProfileFor(1851), AJAZZ_AJ179_PRO_PROFILE); + assert.equal(AJAZZ_AJ179_PRO_PROFILE.buttons?.find((button) => button.name === "Back")?.slot, 4); + assert.equal(AJAZZ_AJ179_PRO_PROFILE.buttons?.find((button) => button.name === "Forward")?.slot, 3); + assert.equal(AJAZZ_AJ179_PRO_PROFILE.dpiStageCountIsCapacity, true); + assert.equal(AJAZZ_AJ179_PRO_PROFILE.batteryPercentageOnly, true); + for (const id of [2285, 1893, 1643, 3310, 0]) { + const profile = gearHubProfileFor(id); + assert.equal(profile.buttons, undefined); + assert.equal(profile.dpiStageCountIsCapacity, undefined); + assert.equal(profile.batteryPercentageOnly, undefined); + } + assert.equal(GEARHUB_BUTTONS.find((button) => button.name === "Back")?.slot, 3); + assert.equal(GEARHUB_BUTTONS.find((button) => button.name === "Forward")?.slot, 4); +}); diff --git a/src/gearhub/index.ts b/src/gearhub/index.ts index 48ef981..d7143b8 100644 --- a/src/gearhub/index.ts +++ b/src/gearhub/index.ts @@ -10,7 +10,7 @@ * makers ship on it. A GearHub-V5 mouse is identified by the GET_USB_VERSION * *device id* (the same key GearHub's own bundle looks its model table up by), * never by product id — the receiver's VID:PID is shared. The product id only - * settles transport (2.4 GHz receiver vs. direct cable). + * settles transport (2.4 GHz receiver, direct cable, or Bluetooth). * * Everything here was read out of GearHub-V5's own JS bundle and confirmed * byte for byte on hardware: a Lingbao M5 Pro (PAW3395, device id 2285) and an @@ -23,6 +23,26 @@ export const GEARHUB_REPORT_ID = 0x00; export const GEARHUB_REPORT_SIZE = 64; export const GEARHUB_CMD_SIZE = 9; +/** AJ179 PRO Bluetooth vendor collection and report framing. */ +export const GEARHUB_BLUETOOTH_USAGE_PAGE = 0xff55; +export const GEARHUB_BLUETOOTH_USAGE = 0x0202; +export const GEARHUB_BLUETOOTH_REPORT_ID = 6; +export const GEARHUB_BLUETOOTH_REPORT_SIZE = 65; +export const GEARHUB_BLUETOOTH_ENVELOPE = 0x55; +export const GEARHUB_BLUETOOTH_BATTERY = 0x77; +export const GEARHUB_BLUETOOTH_SLEEPING = 0x88; + +/** Wrap an already-checksummed USB block; WebHID supplies report id separately. */ +export function gearHubBluetoothPacket(encoded: Uint8Array): Uint8Array { + if (encoded.length > GEARHUB_REPORT_SIZE) { + throw new Error(`GearHub Bluetooth command exceeds ${GEARHUB_REPORT_SIZE} bytes.`); + } + const packet = new Uint8Array(GEARHUB_BLUETOOTH_REPORT_SIZE); + packet[0] = GEARHUB_BLUETOOTH_ENVELOPE; + packet.set(encoded, 1); + return packet; +} + /** DPI stages the report layout has room for. */ export const GEARHUB_MAX_DPI_STAGES = 8; @@ -210,7 +230,7 @@ export function encodeCommand(bytes: readonly number[]): Uint8Array { /** What a product id settles on its own: how the mouse is reached. */ export interface GearHubTransport { - transport: "dongle" | "direct"; + transport: "dongle" | "direct" | "bluetooth"; /** Polling ceiling this link imposes regardless of the sensor. */ wiredPollingCeilingHz?: number; } @@ -220,13 +240,14 @@ export interface GearHubTransport { * settles: transport. The M5 Pro presents the 2.4 GHz receiver (0x402D) and, * by cable, the mouse directly (0x4026). * - * Bluetooth is a third mode not listed here: over BLE the device enumerates on - * a different usage page entirely (0xFF35/0xFF66) and GearHub drives it through - * a separate read path this driver does not implement. + * AJ179 PRO Bluetooth is PID 0x402C, usage page 0xFF55, usage 0x0202. + * Its report-6 output/input transport wraps the same blocks in byte 0x55. + * Other Bluetooth product ids are not claimed without hardware evidence. */ export const GEARHUB_PRODUCTS: ReadonlyMap = new Map([ [0x402d, { transport: "dongle" }], [0x4026, { transport: "direct", wiredPollingCeilingHz: 1000 }], + [0x402c, { transport: "bluetooth" }], ]); // ── Device catalog (by GET_USB_VERSION device id) ────────────────────────── @@ -238,13 +259,19 @@ export const GEARHUB_PRODUCTS: ReadonlyMap = new Map([ */ export interface GearHubProfile { deviceId: number; - brand: "Lingbao" | "Attack Shark" | "GearHub"; + brand: "Lingbao" | "Attack Shark" | "GearHub" | "AJAZZ"; model: string; sensor: string; maxPollingHz: number; minDpi: number; maxDpi: number; dpiStep: number; + /** Physical button slots when they differ from the shared R2 layout. */ + buttons?: readonly GearHubButton[]; + /** GET_DPI includes trailing disabled slots, not just enabled stages. */ + dpiStageCountIsCapacity?: boolean; + /** Battery percentage is known, but no charging-state field is established. */ + batteryPercentageOnly?: boolean; } /** @@ -318,10 +345,31 @@ export const ATTACK_SHARK_R3_3950_PROFILE: GearHubProfile = { maxDpi: 42000, }; +/** AJ179 PRO, device id 1851. Verified on receiver, USB cable and Bluetooth. + * Vendor AJAZZ Driver (R) also maps id 1851 to its PAW3395 AJ179 PRO profile. + * See docs/ajazz-aj179-pro.md for measured capabilities and limitations. */ +export const AJAZZ_AJ179_PRO_PROFILE: GearHubProfile = { + deviceId: 1851, + brand: "AJAZZ", + model: "AJ179 PRO", + sensor: "PixArt PAW3395", + maxPollingHz: 8000, + minDpi: 50, + maxDpi: 26000, + dpiStep: 50, + buttons: GEARHUB_BUTTONS.map((button) => ({ + ...button, + slot: button.name === "Back" ? 4 : button.name === "Forward" ? 3 : button.slot, + })), + dpiStageCountIsCapacity: true, + batteryPercentageOnly: true, +}; + export const GEARHUB_DEVICE_PROFILES: ReadonlyMap = new Map< number, GearHubProfile >([ + [AJAZZ_AJ179_PRO_PROFILE.deviceId, AJAZZ_AJ179_PRO_PROFILE], [LINGBAO_M5_PRO_PROFILE.deviceId, LINGBAO_M5_PRO_PROFILE], [ATTACK_SHARK_R2_PROFILE.deviceId, ATTACK_SHARK_R2_PROFILE], // A later R2 firmware batch on the same 0x40xx silicon, PID and PAW3950