Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions captures/ajazz-aj179-pro.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
115 changes: 115 additions & 0 deletions docs/ajazz-aj179-pro.md
Original file line number Diff line number Diff line change
@@ -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
<https://www.a-jazz.com/h-col-156.html>.

## 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.
178 changes: 178 additions & 0 deletions src/drivers/gearhub/bluetooth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading