From c80782619587f691d8f12a3a2cc95b9fb512ac84 Mon Sep 17 00:00:00 2001 From: NotLokry Date: Sun, 25 Oct 2026 19:58:07 +0200 Subject: [PATCH 1/2] a --- package-lock.json | 18 ++++++++++++++++++ package.json | 1 + 2 files changed, 19 insertions(+) diff --git a/package-lock.json b/package-lock.json index 598983c4..9b997f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "@openmouse/protocol", "version": "0.1.0", "devDependencies": { + "@types/node": "^26.2.0", "tsx": "^4.23.11", "typescript": "^5.8.3" }, @@ -457,6 +458,16 @@ "node": ">=18" } }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -546,6 +557,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 71891fa0..b81d4e07 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "prepare": "npm run build" }, "devDependencies": { + "@types/node": "^26.2.0", "tsx": "^4.23.11", "typescript": "^5.8.3" }, From 92a05f37c022498af2b11b50238c3b052619bfac Mon Sep 17 00:00:00 2001 From: NotLokry Date: Sun, 25 Oct 2026 21:48:59 +0200 Subject: [PATCH 2/2] Fixed the lightforce switch exception for G309 and seperated axis to end the dpi flash fail bug. --- docs/logitech-testing.md | 17 +++---- src/drivers/logitech/hidpp.test.ts | 71 +++++++++++++++++++++++++++--- src/drivers/logitech/hidpp.ts | 51 +++++++++++++++------ 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/docs/logitech-testing.md b/docs/logitech-testing.md index f04024c6..5bc88708 100644 --- a/docs/logitech-testing.md +++ b/docs/logitech-testing.md @@ -166,17 +166,18 @@ legacy `0x2201` DPI and `0x8060` report rate, profile format 2 (LOGAN). ## G309 LIGHTSPEED (receiver-attached, Model ID `B03C40B10000`) -The G309 exposes Extended Adjustable DPI `0x2202` and Mode Status `0x8090`, but -only the power-mode half of Mode Status is meaningful: the status1 byte that -would carry the gaming-surface and LightForce fields is reserved and reads 0. -The `0x2202` sensor likewise reports lift-off level 0, the feature's "no -lift-off control" value. OpenMouse treats both as absent, so those cards stay -hidden. +The G309 exposes Extended Adjustable DPI `0x2202` and Mode Status `0x8090`. +Its `0x2202` sensor reports lift-off level 0, the feature's "no lift-off +control" value, so the lift-off and gaming-surface controls stay hidden. The +G309 does have LightForce switches; its model ID is an exception to the usual +lift-off-control gate so its LightForce mode remains available. + +Hardware-tested: changing the G309's DPI succeeds and takes effect. 1. Confirm the model, battery, connection type, DPI, and polling rate are read correctly. -2. Confirm the sensor card (lift-off distance), the gaming-surface card, and - the LightForce switch are all hidden. +2. Confirm the sensor card and gaming-surface card are hidden, while the + LightForce switch mode is available. 3. Change the DPI and polling rate and confirm each write persists after a reload. diff --git a/src/drivers/logitech/hidpp.test.ts b/src/drivers/logitech/hidpp.test.ts index 6da0902c..832917cc 100644 --- a/src/drivers/logitech/hidpp.test.ts +++ b/src/drivers/logitech/hidpp.test.ts @@ -19,6 +19,7 @@ import { import { LogitechHidppClient, connectionDetailFor, + hasLightforceModeControl, hasLiftOffControl, isPowerOnlyModeStatus, isWiredHidppConnection, @@ -175,13 +176,19 @@ test("an onboard-only mouse is told how to get itself supported", () => { assert.doesNotMatch(unknown.message, /format (\d|null)/); assert.match(unknown.message, /Copy verification data/); }); -test("the G309's mode status is power-only and exposes no surface or LightForce controls", () => { - // Model id captured from hardware: 0x8090 V2 with only the power-mode half. - assert.equal(isPowerOnlyModeStatus("B03C40B10000"), true); - // The G305 reports the same reserved status1 byte. - assert.equal(isPowerOnlyModeStatus("407400000000"), true); - // Every other model keeps the status1 fields, and unknown/absent ids must - // not be silently downgraded. +test("G309 exposes LightForce mode without lift-off control, while G305 is power-only", () => { + const g309 = "B03C40B10000"; + const g305 = "407400000000"; + assert.equal(isPowerOnlyModeStatus(g309), false); + assert.equal(hasLightforceModeControl(g309, false, false), true); + assert.equal(hasLightforceModeControl(g309, false, true), false, "HITS takes precedence"); + + assert.equal(isPowerOnlyModeStatus(g305), true); + assert.equal(hasLightforceModeControl(g305, false, false), false); + assert.equal(hasLightforceModeControl("unknown", false, false), false); + assert.equal(hasLightforceModeControl("unknown", true, false), true); + + // Unknown/absent ids must not be silently downgraded. assert.equal(isPowerOnlyModeStatus("B03C40B10001"), false); assert.equal(isPowerOnlyModeStatus(""), false); assert.equal(isPowerOnlyModeStatus(null), false); @@ -327,6 +334,56 @@ async function resolveIndex(client: LogitechHidppClient): Promise return driver.resolvedDeviceIndex; } +test("extended DPI writes zero for unsupported Y axes", async () => { + const { client } = harness(LIGHTSPEED_RECEIVER, { 1: "mouse" }); + const driver = client as unknown as { + resolveDpiFeature(): Promise<{ index: number; legacy: boolean }>; + getDpiOptions(): Promise; + ensureHostControl(): Promise; + getFeature(featureId: number): Promise<{ index: number; version: number }>; + readDpiConfiguration(featureIndex: number): Promise<{ x: number; y: number; lod: number }>; + readDpiCapabilities(featureIndex: number): Promise<{ separateAxes: boolean; liftOff: boolean }>; + requestLong(featureIndex: number, functionId: number, parameters: number[]): Promise; + }; + let configurationReads = 0; + let writeParameters: number[] = []; + driver.resolveDpiFeature = async () => ({ index: 0x07, legacy: false }); + driver.getDpiOptions = async () => [800, 1600]; + driver.ensureHostControl = async () => {}; + driver.getFeature = async () => ({ index: 0x07, version: 1 }); + driver.readDpiCapabilities = async () => ({ separateAxes: false, liftOff: false }); + driver.readDpiConfiguration = async () => { + configurationReads += 1; + return configurationReads === 1 + ? { x: 800, y: 0, lod: 3 } + : { x: 1600, y: 0, lod: 0 }; + }; + driver.requestLong = async (_featureIndex, functionId, parameters) => { + assert.equal(functionId, 0x60); + writeParameters = parameters; + return new Uint8Array(); + }; + + assert.equal(await client.setDpi(1600), 1600); + assert.deepEqual(writeParameters, [0x00, 0x06, 0x40, 0x00, 0x00, 0x00]); +}); + +test("HID++ rejections identify the feature and function that failed", async () => { + const { client, device } = harness(LIGHTSPEED_RECEIVER, { 1: "mouse" }); + await resolveIndex(client); + device.onRequest = (request) => request[1] === 0x07 && request[2] === withSoftwareId(0x60) + ? new Uint8Array([request[0], 0xff, request[1], request[2], 0x02, 0, 0]) + : null; + const driver = client as unknown as { + requestLong(featureIndex: number, functionId: number, parameters: number[]): Promise; + }; + + await assert.rejects( + driver.requestLong(0x07, 0x60, [0, 6, 64, 0, 0, 0]), + /invalid argument.*feature 0x07, function 0x60/, + ); +}); + /** * Simulates `readStatus()`'s own retry: clear the already-resolved (and, by * the time a real caller does this, already proven sensorless) index and diff --git a/src/drivers/logitech/hidpp.ts b/src/drivers/logitech/hidpp.ts index f4c61883..0bf54eef 100644 --- a/src/drivers/logitech/hidpp.ts +++ b/src/drivers/logitech/hidpp.ts @@ -274,15 +274,29 @@ const KNOWN_RECEIVER_PRODUCT_IDS: ReadonlySet = new Set(LOGITECH_RECEIVE * model id, like the Superstrike detection. */ const MODE_STATUS_POWER_ONLY_MODEL_IDS: ReadonlySet = new Set([ - "B03C40B10000", // G309 LIGHTSPEED "407400000000", // G305 LIGHTSPEED ]); +const LIGHTFORCE_MODE_WITHOUT_LIFT_OFF_MODEL_IDS: ReadonlySet = new Set([ + "B03C40B10000", // G309 LIGHTSPEED +]); + /** Whether 0x8090 on this model is the power-mode-only variant. */ export function isPowerOnlyModeStatus(modelId: string | null | undefined): boolean { return MODE_STATUS_POWER_ONLY_MODEL_IDS.has(modelId ?? ""); } +/** Whether the model exposes LightForce mode, even without a live lift-off control. */ +export function hasLightforceModeControl( + modelId: string | null | undefined, + hasLiveLiftOffControl: boolean, + hasAnalogButtons: boolean, +): boolean { + return !isPowerOnlyModeStatus(modelId) + && !hasAnalogButtons + && (hasLiveLiftOffControl || LIGHTFORCE_MODE_WITHOUT_LIFT_OFF_MODEL_IDS.has(modelId ?? "")); +} + /** * Whether the mouse exposes lift-off levels it can drive: only extended DPI * (0x2202) carries one, and its byte 0 is the "no lift-off control" value, so @@ -530,7 +544,11 @@ export class LogitechHidppClient { return failure !== null; }); if (failedIndex >= 0) { - this.waiters.splice(failedIndex, 1)[0].reject(new Error(failure ?? "The mouse rejected that request.")); + const [waiter] = this.waiters.splice(failedIndex, 1); + const request = waiter + ? ` (feature 0x${waiter.featureIndex.toString(16).padStart(2, "0")}, function 0x${waiter.functionId.toString(16).padStart(2, "0")})` + : ""; + waiter?.reject(new Error(`${failure ?? "The mouse rejected that request."}${request}`)); } return; } @@ -913,9 +931,8 @@ export class LogitechHidppClient { onboardProfileFormat, // HID++ exposes no per-field support mask for status1. A power-only // variant can leave it reserved at zero, which would falsely decode as - // Surface Auto and LightForce Optical. Only expose that control bank when - // the sensor positively reports the related live LOD capability; this is - // deliberately conservative and avoids a product/model exception. + // Surface Auto and LightForce Optical. Require live LOD capability + // except for models whose status fields have been verified separately. ...wheel, friendlyName: friendly?.name ?? null, friendlyNameMaxLength: friendly?.maxLength ?? null, @@ -925,10 +942,14 @@ export class LogitechHidppClient { hapticIntensity: haptics?.intensity ?? null, hapticEnabled: haptics?.enabled ?? null, hapticBatterySaving: haptics?.batterySaving ?? null, - gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl + gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl || isPowerOnlyModeStatus(identity.modelId) ? null : decodeModeStatus(modeStatus, MODE_STATUS.gamingSurface), - lightforceSwitchMode: modeStatus === null || !hasLiveLiftOffControl || !hasLightforceSwitches(Boolean(analogButtonsFeature.index)) + lightforceSwitchMode: modeStatus === null || !hasLightforceModeControl( + identity.modelId, + hasLiveLiftOffControl, + Boolean(analogButtonsFeature.index), + ) ? null : decodeModeStatus(modeStatus, MODE_STATUS.lightforce), lighting: lighting ?? undefined, @@ -1232,16 +1253,19 @@ export class LogitechHidppClient { await this.ensureHostControl(); const feature = await this.getFeature(FEATURE.extendedDpi); const current = await this.readDpiConfiguration(feature.index); + const capabilities = await this.readDpiCapabilities(feature.index); + const writeDpiY = capabilities.separateAxes ? dpiY : 0; + const writeLod = capabilities.liftOff ? current.lod : 0; await this.requestLong(feature.index, 0x60, [ 0x00, dpi >> 8, dpi & 0xff, - dpiY >> 8, - dpiY & 0xff, - current.lod, + writeDpiY >> 8, + writeDpiY & 0xff, + writeLod, ]); const confirmed = await this.readDpiConfiguration(feature.index); - if (confirmed.x !== dpi || confirmed.y !== dpiY) { + if (confirmed.x !== dpi || (capabilities.separateAxes && confirmed.y !== dpiY)) { throw new Error(`The mouse kept ${confirmed.x}/${confirmed.y} DPI instead of ${dpi}/${dpiY} DPI.`); } return confirmed.x; @@ -1260,12 +1284,13 @@ export class LogitechHidppClient { throw new Error("This mouse does not expose lift-off-distance controls."); } const current = await this.readDpiConfiguration(feature.index); + const capabilities = await this.readDpiCapabilities(feature.index); await this.requestLong(feature.index, 0x60, [ 0x00, current.x >> 8, current.x & 0xff, - current.y >> 8, - current.y & 0xff, + capabilities.separateAxes ? current.y >> 8 : 0, + capabilities.separateAxes ? current.y & 0xff : 0, lod, ]); const confirmed = await this.readDpiConfiguration(feature.index);