diff --git a/src/drivers/attackshark/hid.test.ts b/src/drivers/attackshark/hid.test.ts index 9d41233..1a5b90d 100644 --- a/src/drivers/attackshark/hid.test.ts +++ b/src/drivers/attackshark/hid.test.ts @@ -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 { @@ -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 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); diff --git a/src/drivers/attackshark/hid.ts b/src/drivers/attackshark/hid.ts index 13db5f8..3a3c607 100644 --- a/src/drivers/attackshark/hid.ts +++ b/src/drivers/attackshark/hid.ts @@ -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) @@ -42,7 +54,9 @@ const POLLING_RATES_1D57: ReadonlyArray = [ 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. @@ -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 = new Set([0xfa55, 0xfa60, 0xfa61]); + +const X11_FAMILY_NAMES: ReadonlyMap = new Map([ + [0xfa55, "Attack Shark X11 (wired)"], + [0xfa60, "Attack Shark X11 (wireless receiver)"], + [0xfa61, "Attack Shark R1"], +]); + +const X11_FAMILY_MODELS: ReadonlyMap = 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; @@ -138,6 +210,8 @@ export class AttackSharkHidClient { private readonly family: ProtocolFamily; private lastStatus: MouseStatus | null = null; private queue: Promise = Promise.resolve(); + private batteryPercent: number | null = null; + private listening = false; constructor(device: HIDDevice) { this.device = device; @@ -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 { 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 { this.lastStatus = null; + if (this.listening) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listening = false; + } if (this.device.opened) await this.device.close(); } @@ -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}`; @@ -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 || ""); } @@ -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(), @@ -226,6 +345,12 @@ export class AttackSharkHidClient { } async setPollingRate(pollingRateHz: number): Promise { + 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.`); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 0afa86e..163b8b3 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -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. */