diff --git a/native-hid/README.md b/native-hid/README.md index 3baf9c6..e4bc62b 100644 --- a/native-hid/README.md +++ b/native-hid/README.md @@ -39,6 +39,13 @@ for that brand's vendor id(s) and tries each candidate class directly: throw is treated as correct, and `setDpi()`/`setPollingRate()` are called on it. +The Attack Shark X11 family is the exception. Its settings channel is not +visible through WebHID, so `apply.mjs` first checks for VID `0x1d57`, PIDs +`0xfa55`/`0xfa60`/`0xfa61`, interface 2 and sends its verified polling feature +report directly through `node-hid`. This keeps the normal operating-system +HID driver in place; WinUSB and Zadig are not required. Native X11 DPI support +is deliberately rejected until its six stages can be read and preserved. + ## Exit code contract (Bridge depends on this — see `src/drivers/native_hid.rs`) - `0` — a device was found and the requested settings were applied. @@ -81,10 +88,12 @@ runtime first and falls back to `node` on `PATH`, so local development ## Status -- The adapter and dispatch logic are structurally tested (module resolution, - exit codes, control flow) but have not been run against real hardware from - this environment — no gaming mouse was reachable here. Validate against an - actual device before relying on this for a non-Pulsar mouse. +- Attack Shark X11 polling was hardware-verified on Windows with the wireless + PID `0xfa60`, interface 2, using the standard Microsoft HID driver. Switching + between 500 Hz and 1000 Hz succeeded without WinUSB or Zadig. +- Other adapter and dispatch paths are structurally tested (module resolution, + exit codes and control flow) and still require validation on their respective + hardware. - G-Wolves is deliberately left out of `brands.mjs`: it isn't a finished, exported driver in `mouse-protocol` yet (untracked source, no `package.json` export, no `registry.ts` entry as of this writing). diff --git a/native-hid/package.json b/native-hid/package.json index 62c7d9a..1874019 100644 --- a/native-hid/package.json +++ b/native-hid/package.json @@ -4,6 +4,9 @@ "private": true, "description": "Node.js helper Bridge spawns to push saved profile DPI/polling-rate settings to a mouse over native HID, reusing OpenMouse's own hardware-verified WebHID driver classes from @openmouse/protocol instead of reimplementing per-vendor protocols.", "type": "module", + "scripts": { + "test": "node --test \"src/**/*.test.mjs\"" + }, "engines": { "node": ">=20" }, diff --git a/native-hid/src/apply.mjs b/native-hid/src/apply.mjs index 5e13c5e..7c2f947 100644 --- a/native-hid/src/apply.mjs +++ b/native-hid/src/apply.mjs @@ -29,6 +29,7 @@ globalThis.window ??= globalThis; import { BRAND_DRIVERS, brandKey } from "./brands.mjs"; +import { applyX11PollingRate, x11DeviceInfos } from "./attack-shark-x11.mjs"; import { candidateDevices } from "./hid-device-adapter.mjs"; const EXIT_APPLIED = 0; @@ -74,12 +75,23 @@ async function probe(device, candidate) { async function main() { const input = JSON.parse(await readStdin()); const brand = typeof input.brand === "string" ? input.brand : ""; - const entry = BRAND_DRIVERS[brandKey(brand)]; + const normalizedBrand = brandKey(brand); + const entry = BRAND_DRIVERS[normalizedBrand]; if (!entry) { console.error(`[native-hid] no driver registered for brand "${brand}"`); process.exit(EXIT_NO_DRIVER); } + if (normalizedBrand === "attack shark" && x11DeviceInfos().length > 0) { + if (Number.isFinite(input.dpi)) { + throw new Error("Attack Shark X11 native DPI control is not implemented yet; polling-only requests are supported."); + } + if (Number.isFinite(input.pollingRateHz)) { + applyX11PollingRate(input.pollingRateHz); + process.exit(EXIT_APPLIED); + } + } + const attempts = []; let client = null; outer: for (const vendorId of entry.vendorIds) { diff --git a/native-hid/src/attack-shark-x11.mjs b/native-hid/src/attack-shark-x11.mjs new file mode 100644 index 0000000..8a62956 --- /dev/null +++ b/native-hid/src/attack-shark-x11.mjs @@ -0,0 +1,55 @@ +import { HID, devices } from "node-hid"; + +export const X11_VENDOR_ID = 0x1d57; +export const X11_PRODUCT_IDS = new Set([0xfa55, 0xfa60, 0xfa61]); +export const X11_SETTINGS_INTERFACE = 2; + +const POLLING_CODES = new Map([ + [125, 0x08], + [250, 0x04], + [500, 0x02], + [1000, 0x01], +]); + +export function x11DeviceInfos(infos = devices()) { + return infos.filter((info) => + info.vendorId === X11_VENDOR_ID + && X11_PRODUCT_IDS.has(info.productId) + && info.interface === X11_SETTINGS_INTERFACE + && typeof info.path === "string" + && info.path.length > 0, + ); +} + +export function buildX11PollingReport(pollingRateHz) { + const code = POLLING_CODES.get(pollingRateHz); + if (code === undefined) { + throw new Error(`Attack Shark X11 does not support ${pollingRateHz} Hz; expected 125, 250, 500, or 1000 Hz.`); + } + return [0x06, 0x09, 0x01, code, (0xff - code) & 0xff, 0x00, 0x00, 0x00, 0x00]; +} + +export function applyX11PollingRate( + pollingRateHz, + { infos = devices(), open = (path) => new HID(path) } = {}, +) { + const candidates = x11DeviceInfos(infos); + if (candidates.length === 0) return false; + + const report = buildX11PollingReport(pollingRateHz); + const attempts = []; + for (const candidate of candidates) { + let device; + try { + device = open(candidate.path); + device.sendFeatureReport(report); + return true; + } catch (error) { + attempts.push(`PID 0x${candidate.productId.toString(16)} interface 2: ${error.message}`); + } finally { + device?.close(); + } + } + + throw new Error(`No Attack Shark X11 settings interface accepted the polling report. Tried:\n ${attempts.join("\n ")}`); +} diff --git a/native-hid/src/attack-shark-x11.test.mjs b/native-hid/src/attack-shark-x11.test.mjs new file mode 100644 index 0000000..51b239f --- /dev/null +++ b/native-hid/src/attack-shark-x11.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyX11PollingRate, + buildX11PollingReport, + x11DeviceInfos, +} from "./attack-shark-x11.mjs"; + +test("buildX11PollingReport matches the verified feature reports", () => { + assert.deepEqual(buildX11PollingReport(125), [0x06, 0x09, 0x01, 0x08, 0xf7, 0, 0, 0, 0]); + assert.deepEqual(buildX11PollingReport(250), [0x06, 0x09, 0x01, 0x04, 0xfb, 0, 0, 0, 0]); + assert.deepEqual(buildX11PollingReport(500), [0x06, 0x09, 0x01, 0x02, 0xfd, 0, 0, 0, 0]); + assert.deepEqual(buildX11PollingReport(1000), [0x06, 0x09, 0x01, 0x01, 0xfe, 0, 0, 0, 0]); + assert.throws(() => buildX11PollingReport(2000), /does not support 2000 Hz/); +}); + +test("x11DeviceInfos selects only X11-family interface 2 paths", () => { + const match = { vendorId: 0x1d57, productId: 0xfa60, interface: 2, path: "x11" }; + assert.deepEqual(x11DeviceInfos([ + match, + { ...match, interface: 1, path: "wrong-interface" }, + { ...match, productId: 0x1234, path: "wrong-product" }, + { ...match, vendorId: 0x25a7, path: "wrong-vendor" }, + { ...match, path: undefined }, + ]), [match]); +}); + +test("applyX11PollingRate sends the feature report and always closes the handle", () => { + const reports = []; + let closed = false; + const applied = applyX11PollingRate(500, { + infos: [{ vendorId: 0x1d57, productId: 0xfa55, interface: 2, path: "wired" }], + open: () => ({ + sendFeatureReport: (report) => reports.push(report), + close: () => { closed = true; }, + }), + }); + + assert.equal(applied, true); + assert.deepEqual(reports, [[0x06, 0x09, 0x01, 0x02, 0xfd, 0, 0, 0, 0]]); + assert.equal(closed, true); +}); + +test("applyX11PollingRate falls through when no X11 settings interface exists", () => { + assert.equal(applyX11PollingRate(1000, { infos: [], open: () => assert.fail("must not open") }), false); +}); diff --git a/src/api.rs b/src/api.rs index 00acfa4..169b435 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,7 +12,7 @@ use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::Tra use crate::{ config::{ApplicationProfile, GameConfig}, - platform, + drivers, platform, service::{BatteryReading, BridgeService}, }; @@ -40,6 +40,14 @@ struct ProfilesPayload { profiles: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NativeSettingsPayload { + brand: String, + dpi: Option, + polling_rate_hz: Option, +} + pub fn router(service: BridgeService, origins: &[String]) -> Router { let allowed: Vec = origins .iter() @@ -58,6 +66,7 @@ pub fn router(service: BridgeService, origins: &[String]) -> Router { .route("/v1/applications/{icon_id}/icon", get(application_icon)) .route("/v1/profiles", get(profiles).put(replace_profiles)) .route("/v1/default-profile", put(set_default_profile)) + .route("/v1/native/settings", put(apply_native_settings)) .route("/v1/battery", put(record_battery)) .route("/v1/autostart", put(set_autostart)) .layer(SetResponseHeaderLayer::if_not_present( @@ -158,6 +167,37 @@ async fn set_autostart( Ok(Json(ApiResult { ok: true })) } +async fn apply_native_settings( + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if payload.dpi.is_none() && payload.polling_rate_hz.is_none() { + return Err(( + StatusCode::BAD_REQUEST, + "at least one of dpi or pollingRateHz is required".into(), + )); + } + + let NativeSettingsPayload { + brand, + dpi, + polling_rate_hz, + } = payload; + let error_brand = brand.clone(); + let applied = + tokio::task::spawn_blocking(move || drivers::apply_settings(&brand, dpi, polling_rate_hz)) + .await + .map_err(|error| internal_error(anyhow::anyhow!(error)))? + .map_err(internal_error)?; + + if !applied { + return Err(( + StatusCode::NOT_FOUND, + format!("no native driver is registered for {error_brand}"), + )); + } + Ok(Json(ApiResult { ok: true })) +} + fn internal_error(error: anyhow::Error) -> (StatusCode, String) { tracing::error!(%error, "Bridge request failed"); (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) diff --git a/src/config.rs b/src/config.rs index 5023d68..e2d5db9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; const DEFAULT_BATTERY_THRESHOLD: u8 = 20; const DEFAULT_ALERT_COOLDOWN_MINUTES: u64 = 360; const OFFICIAL_ORIGINS: &[&str] = &[ + "https://control.openmouse.app", "https://dev.openmouse.app", "https://openmouse.app", "https://www.openmouse.app", @@ -187,6 +188,7 @@ const fn default_alert_cooldown() -> u64 { fn default_origins() -> Vec { vec![ + "https://control.openmouse.app".to_owned(), "https://dev.openmouse.app".to_owned(), "https://openmouse.app".to_owned(), "https://www.openmouse.app".to_owned(), diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index dd731ce..9750091 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -50,6 +50,13 @@ pub fn apply_profile(profile: &ApplicationProfile) -> Result { } } +/// Applies settings requested directly by the OpenMouse web UI through the +/// local Bridge. Native-only devices use this path when WebHID cannot access +/// their protected configuration interface. +pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { + native_hid::apply_settings(brand, dpi, polling_rate_hz) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index ddad47f..a733198 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -39,12 +39,23 @@ struct ApplyRequest<'a> { /// web app over WebHID instead), and `Err` when a driver exists but the /// apply itself failed (including the helper or Node.js being unavailable). pub fn apply(profile: &ApplicationProfile) -> Result { - let script = locate_apply_script()?; let brand = profile.device.id.split(':').next().unwrap_or_default(); + apply_settings( + brand, + profile.settings.dpi, + profile.settings.polling_rate_hz, + ) +} + +/// Pushes an explicit settings request from the local HTTP API. This shares +/// the exact helper path used by automatic profiles, so native device control +/// and profile switching cannot drift into separate protocol implementations. +pub fn apply_settings(brand: &str, dpi: Option, polling_rate_hz: Option) -> Result { + let script = locate_apply_script()?; let request = ApplyRequest { brand, - dpi: profile.settings.dpi, - polling_rate_hz: profile.settings.polling_rate_hz, + dpi, + polling_rate_hz, }; let payload = serde_json::to_vec(&request).context("could not encode the native-hid request")?;