Skip to content
Open
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
110 changes: 109 additions & 1 deletion src/drivers/attackshark/hid.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";

import { AttackSharkHidClient, checksum25a7, POLLING_CODES_25A7 } from "./hid.ts";
import {
AttackSharkHidClient,
attackSharkNativeOnlyMessage,
checksum25a7,
POLLING_CODES_25A7,
} from "./hid.ts";

function device(vendorId: number, usagePage = 0xffff): HIDDevice {
return {
Expand All @@ -26,6 +31,109 @@ test("Attack Shark OEM devices require the expected feature-report collection",
assert.equal(AttackSharkHidClient.isSupported(device(0x25a7, 0x0001)), false);
});

// The four HID entries a real X11 wireless receiver (0x1d57:0xfa60) presents
// to Chrome: two keyboards, a boot mouse, and a system-control/consumer
// composite — none with visible feature reports (Chrome hides reports on
// protected keyboard/system collections; nothing else declares any).
function x11Entry(productId: number, collections: Array<[number, number]>): HIDDevice {
return {
vendorId: 0x1d57,
productId,
productName: "2.4G Wireless Device",
collections: collections.map(([usagePage, usage]) => ({
usagePage,
usage,
type: 0,
children: [],
inputReports: [],
outputReports: [],
featureReports: [],
})),
} as unknown as HIDDevice;
}

test("X11 boot entries are refused; the composite status entry is claimed", () => {
const refused = [
x11Entry(0xfa60, [[0x01, 0x06]]),
x11Entry(0xfa60, [[0x01, 0x06]]),
x11Entry(0xfa60, [[0x01, 0x02]]),
];
for (const entry of refused) {
assert.equal(AttackSharkHidClient.isSupported(entry), false);
}
const composite = x11Entry(0xfa60, [[0x01, 0x80], [0x0c, 0x01], [0x0a, 0x00], [0x0b, 0x00]]);
assert.equal(AttackSharkHidClient.isSupported(composite), true);
// An unknown 0x1d57 PID with the same shape stays refused: the read-only
// claim is scoped to units whose battery stream is documented.
const unknownPid = x11Entry(0x1234, [[0x01, 0x80], [0x0c, 0x01]]);
assert.equal(AttackSharkHidClient.isSupported(unknownPid), false);
});

test("X11 read-only client reports battery from input reports and refuses writes", async () => {
const listeners = new Map<string, (event: unknown) => void>();
const base = x11Entry(0xfa60, [[0x01, 0x80], [0x0c, 0x01]]);
// A unit whose battery report (id 0x03) is visible on the consumer collection.
(base.collections[1] as { inputReports: unknown[] }).inputReports = [{ reportId: 0x03, items: [] }];
const composite = {
...base,
opened: false,
open() { (this as { opened: boolean }).opened = true; return Promise.resolve(); },
close() { (this as { opened: boolean }).opened = false; return Promise.resolve(); },
addEventListener(type: string, handler: (event: unknown) => void) { listeners.set(type, handler); },
removeEventListener(type: string) { listeners.delete(type); },
} as unknown as HIDDevice;

const client = new AttackSharkHidClient(composite);
const before = await client.readStatus();
assert.equal(before.name, "Attack Shark X11");
assert.equal(before.ui?.settingsReady, false);
assert.equal(before.ui?.forceShowBattery, true);
assert.match(before.ui?.statusNote ?? "", /needs a native driver/);
assert.equal(before.batteryPercent, null);
assert.equal(before.connectionType, "Wireless");

// Raw packet 03 55 40 01 50: WebHID moves the leading 0x03 into reportId.
const payload = new Uint8Array([0x55, 0x40, 0x01, 0x50]);
listeners.get("inputreport")?.({ reportId: 0x03, data: new DataView(payload.buffer) });
const after = await client.readStatus();
assert.equal(after.batteryPercent, 80);
assert.equal(after.batteryState, "Discharging");

await assert.rejects(() => client.setPollingRate(1000), /native Attack Shark X11 driver/);
});

test("X11 units whose battery report is hidden do not advertise a battery column", async () => {
// Real receivers declare the battery report under the protected
// system-control collection, which Chrome hides — collections then show
// no input report 0x03 at all (openmouse-1d57-fa60 diagnostics).
const composite = {
...x11Entry(0xfa60, [[0x01, 0x80], [0x0c, 0x01]]),
opened: true,
open: () => Promise.resolve(),
addEventListener: () => undefined,
removeEventListener: () => undefined,
} as unknown as HIDDevice;
(composite.collections[1] as { inputReports: unknown[] }).inputReports = [{ reportId: 0x02, items: [] }];

const client = new AttackSharkHidClient(composite);
const status = await client.readStatus();
assert.equal(status.ui?.forceShowBattery, false);
});

test("X11-family grants get a native-only explanation, other refusals do not", () => {
const wireless = attackSharkNativeOnlyMessage([x11Entry(0xfa60, [[0x01, 0x06]])]);
assert.match(wireless ?? "", /X11 \(wireless receiver\)/);
assert.match(wireless ?? "", /Enable native control/);
assert.match(wireless ?? "", /OpenMouse Bridge/);

const wired = attackSharkNativeOnlyMessage([x11Entry(0xfa55, [[0x01, 0x02]])]);
assert.match(wired ?? "", /X11 \(wired\)/);

// Unknown 0x1d57 PIDs and other vendors keep the generic error.
assert.equal(attackSharkNativeOnlyMessage([x11Entry(0x1234, [[0x01, 0x02]])]), null);
assert.equal(attackSharkNativeOnlyMessage([device(0x25a7)]), null);
});

test("Attack Shark battery reports validate their signature and percentage", () => {
assert.equal(AttackSharkHidClient.parseBatteryReport(new Uint8Array([0x03, 0x55, 0x40, 0x01, 73])), 73);
assert.equal(AttackSharkHidClient.parseBatteryReport(new Uint8Array([0x03, 0x55, 0x40, 0x00, 73])), null);
Expand Down
133 changes: 129 additions & 4 deletions src/drivers/attackshark/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ import { LAMZU_PRODUCTS } from "@openmouse/protocol/lamzu";
// PIDs change between firmware revisions, so detection is collection-based,
// not PID-based. For 0x373e we exclude known Lamzu PIDs.
//
// Real X11 hardware (0x1d57, wired 0xfa55 and wireless 0xfa60) exposes NO
// feature reports to the browser on any of its four HID entries. Its config
// channel is USB interface 2 (a system-control/consumer composite; see the
// lsusb dump in dressedinblack5/attack-shark-x11-electron docs/descritors),
// and the reference driver bypasses the HID stack entirely: it claims
// interface 2 with libusb, detaches the kernel HID driver on Linux, and on
// Windows requires Zadig to swap the driver for WinUSB. A browser can do
// none of that — WebHID sees no feature reports, and WebUSB refuses to
// claim HID-class interfaces. The collection gate below therefore correctly
// refuses these units; the X11-family helper further down turns that
// refusal into a real explanation.
//
// Protocol source: xb-bx/attack-shark-r1-driver (Odin)
// HarukaYamamoto0/attack-shark-x11-driver (TypeScript)
// qmk.top GearHub bundle (MU class — 0x25a7 protocol)
Expand Down Expand Up @@ -42,7 +54,9 @@ const POLLING_RATES_1D57: ReadonlyArray<readonly [number, number]> = [
const DPI_READ_REPORT_ID = 0xa0;

// Battery arrives as input report with this 4-byte signature; byte 4 = %.
// The leading 0x03 is the HID report id of the battery packet.
const BATTERY_SIGNATURE = [0x03, 0x55, 0x40, 0x01];
const BATTERY_REPORT_ID = BATTERY_SIGNATURE[0];

// ── 0x25a7 protocol (GearHub / MU class) ─────────────────────────────────
// Reverse-engineered from the qmk.top GearHub web driver JS bundle.
Expand Down Expand Up @@ -82,13 +96,71 @@ function hasVendorControl(collection: HIDCollectionInfo): boolean {
return collection.children.some(hasVendorControl);
}

function declaresInputReport(collection: HIDCollectionInfo, reportId: number): boolean {
if (collection.inputReports.some((report) => report.reportId === reportId)) return true;
return collection.children.some((child) => declaresInputReport(child, reportId));
}

// ── X11 family (config is native-only; battery is readable) ──────────────

// Documented 0x1d57 PIDs: wired X11, wireless X11 receiver, R1.
const X11_FAMILY_PIDS: ReadonlySet<number> = new Set([0xfa55, 0xfa60, 0xfa61]);

const X11_FAMILY_NAMES: ReadonlyMap<number, string> = new Map([
[0xfa55, "Attack Shark X11 (wired)"],
[0xfa60, "Attack Shark X11 (wireless receiver)"],
[0xfa61, "Attack Shark R1"],
]);

const X11_FAMILY_MODELS: ReadonlyMap<number, string> = new Map([
[0xfa55, "Attack Shark X11"],
[0xfa60, "Attack Shark X11"],
[0xfa61, "Attack Shark R1"],
]);

// The wireless receiver's interface 2 pushes battery packets on its own —
// no command needed — so a read-only claim of that entry costs nothing and
// risks nothing. The wired PIDs never report battery on this endpoint.
const X11_WIRELESS_PID = 0xfa60;

/**
* If the granted devices include an X11-family unit that no driver could
* claim, explain why instead of letting the generic "not a control
* interface" error blame the picker choice. This fires only when the
* status entry (the composite with a Consumer collection) was not among
* the grants — the rows look identical in the picker, so say how to get
* the right one — and it stays honest about settings being native-only.
* Returns null when no X11-family device is present.
*/
export function attackSharkNativeOnlyMessage(devices: HIDDevice[]): string | null {
const unit = devices.find(
(device) => device.vendorId === VID_1D57 && X11_FAMILY_PIDS.has(device.productId),
);
if (!unit) return null;
const name = X11_FAMILY_NAMES.get(unit.productId) ?? "Attack Shark X11";
return `This ${name} cannot be configured through the browser: its settings channel `
+ "is on an interface the browser is not allowed to reach. To change DPI, polling "
+ "rate and lighting, install the OpenMouse Bridge, then open Interface settings "
+ "→ Bridge → Native devices and click “Enable native control”.";
}

// ── Protocol family detection ─────────────────────────────────────────────

type ProtocolFamily = "1d57" | "25a7" | "373e" | null;
type ProtocolFamily = "1d57" | "1d57-x11" | "25a7" | "373e" | null;

function detectFamily(device: HIDDevice): ProtocolFamily {
if (device.vendorId === VID_1D57) {
return device.collections.some(hasFeatureReports) ? "1d57" : null;
if (device.collections.some(hasFeatureReports)) return "1d57";
// Real X11/R1 units declare no feature reports anywhere (see header
// note), so config writes are impossible here — but their interface-2
// entry, the composite with a Consumer top-level collection, carries the
// autonomous battery stream. Claim that one read-only; the plain boot
// keyboard/mouse entries stay refused.
if (X11_FAMILY_PIDS.has(device.productId)
&& device.collections.some((collection) => collection.usagePage === 0x0c)) {
return "1d57-x11";
}
return null;
}
if (device.vendorId === VID_25A7) {
return device.collections.some(hasVendorControl) ? "25a7" : null;
Expand Down Expand Up @@ -138,6 +210,8 @@ export class AttackSharkHidClient {
private readonly family: ProtocolFamily;
private lastStatus: MouseStatus | null = null;
private queue: Promise<unknown> = Promise.resolve();
private batteryPercent: number | null = null;
private listening = false;

constructor(device: HIDDevice) {
this.device = device;
Expand All @@ -148,12 +222,37 @@ export class AttackSharkHidClient {
return detectFamily(device) !== null;
}

// Battery packets arrive as inputreport events; the raw packet's leading
// 0x03 is the HID report id, which WebHID strips into event.reportId, so
// rebuild the native shape before matching the signature.
private readonly onInputReport = (event: HIDInputReportEvent): void => {
const data = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
const packet = new Uint8Array(data.length + 1);
packet[0] = event.reportId;
packet.set(data, 1);
const percent = AttackSharkHidClient.parseBatteryReport(packet);
if (percent !== null) {
this.batteryPercent = percent;
if (this.lastStatus) {
this.lastStatus = { ...this.lastStatus, batteryPercent: percent, batteryState: "Discharging" };
}
}
};

async open(): Promise<void> {
if (!this.device.opened) await this.device.open();
if (this.family === "1d57-x11" && !this.listening) {
this.device.addEventListener("inputreport", this.onInputReport);
this.listening = true;
}
}

async close(): Promise<void> {
this.lastStatus = null;
if (this.listening) {
this.device.removeEventListener("inputreport", this.onInputReport);
this.listening = false;
}
if (this.device.opened) await this.device.close();
}

Expand All @@ -162,6 +261,12 @@ export class AttackSharkHidClient {
}

displayName(): string {
// X11-family product strings are generic OEM labels ("2.4G Wireless
// Device", "USB Gaming Mouse"), so name those models by PID instead.
const model = this.device.vendorId === VID_1D57
? X11_FAMILY_MODELS.get(this.device.productId)
: undefined;
if (model) return model;
const name = this.device.productName?.trim();
if (!name) return "Attack Shark";
return /^attack\s*shark/i.test(name) ? name : `Attack Shark ${name}`;
Expand All @@ -172,6 +277,9 @@ export class AttackSharkHidClient {
}

isWireless(): boolean {
if (this.device.vendorId === VID_1D57 && X11_FAMILY_PIDS.has(this.device.productId)) {
return this.device.productId === X11_WIRELESS_PID;
}
return /receiver|dongle|wireless|2\.4g/i.test(this.device.productName || "");
}

Expand Down Expand Up @@ -212,9 +320,20 @@ export class AttackSharkHidClient {
settingsReady: this.family === "1d57" || this.family === "25a7",
hideUnsupportedPollingRates: true,
hideProcessingCard: true,
// Wireless X11-family units push battery on their own — but only
// show the column when the battery report is actually visible to
// the browser. On known units it is declared under the protected
// system-control collection, so Chrome hides it and the packet can
// never arrive; an always-empty battery column would just confuse.
forceShowBattery: this.family === "1d57-x11"
&& this.isWireless()
&& this.device.collections.some((collection) => declaresInputReport(collection, BATTERY_REPORT_ID)),
statusNote: this.family === "1d57-x11"
? "Status only: this mouse's settings channel is not reachable from a browser and needs a native driver."
: undefined,
},
batteryPercent: null,
batteryState: "Unknown",
batteryPercent: this.batteryPercent,
batteryState: this.batteryPercent !== null ? "Discharging" : "Unknown",
dpi,
pollingRateHz,
supportedPollingRates: this.getSupportedPollingRates(),
Expand All @@ -226,6 +345,12 @@ export class AttackSharkHidClient {
}

async setPollingRate(pollingRateHz: number): Promise<number> {
if (this.family === "1d57-x11") {
throw new Error(
"This mouse's settings channel is not reachable from a browser; "
+ "changing settings needs the native Attack Shark X11 driver.",
);
}
if (this.family === "1d57") {
const entry = POLLING_RATES_1D57.find(([, hz]) => hz === pollingRateHz);
if (!entry) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`);
Expand Down
5 changes: 5 additions & 0 deletions src/drivers/mouse-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export interface MouseUiHints {
showAdvancedSection?: boolean;
/** Always show battery column (even wired with null %). */
forceShowBattery?: boolean;
/**
* Extra sentence appended to the connected status line, for drivers whose
* connection is deliberately limited (e.g. why settings are unavailable).
*/
statusNote?: string;
/** Override the polling-rate footnote. */
pollingNote?: string;
/** Sidebar name before first status read. */
Expand Down