From 349a63865786f906a61bf082b7695be1d6c030c1 Mon Sep 17 00:00:00 2001 From: Pochiiko Date: Thu, 6 Aug 2026 03:25:41 +1000 Subject: [PATCH 1/3] feat(razer): map lift-off distance on the Viper V3 Pro OpenRazer has had this open as issue #783, tagged help wanted, since 2019. Class 0x0b is now fully mapped, and every claim below was verified at the sensor by lifting the mouse rather than at a status byte. read 0x0b/0x85 dataSize 0x05 echo, 00, tracking, liftOff-1, landing-1 pair 0x0b/0x05 dataSize 0x0a 00, 04, liftOff-1, landing-1, 00 x 6 setting 0x0b/0x0b dataSize 0x04 00, 04, setting, value setting 01 = tracking level 00/01/02 setting 04 = asymmetric unlock, value 01 Three things are easy to get wrong here, so they are enforced rather than merely documented. The pair write is refused in symmetric mode and still moves what the read reports. Omitting the unlock therefore produces a driver that throws, shows an error, and then reads back exactly the values the user asked for while the sensor holds the old ones. setLiftOff always sends the unlock first. There is no mode bit, and nothing readable reports one. The mouse honours whichever store was written last, so writing a tracking level is what leaves asymmetric mode. probeAsymmetric recovers the current mode from the pair write's own status, re-sending the values already stored so it cannot disturb anything, and runs once per connection because it is still a write. The firmware stores an inverted pair without complaint. One session left the mouse holding lift-off 2 with landing 26, which the vendor software cannot express. razerMaxLanding is the single source of that rule. readLiftOff degrades to null rather than throwing. Class 0x0b has only been exercised on the receiver, and an uncaught read would fail the whole status read instead of one control. MouseStatus gains an optional asymmetricLiftOff for the pair. liftOffDistance is unchanged and no other driver is touched. --- src/devices/mouse-types.ts | 16 ++ src/devices/razer/TESTING.md | 174 ++++++++++++++++++-- src/devices/razer/hid.test.ts | 247 +++++++++++++++++++++++++++++ src/devices/razer/hid.ts | 145 ++++++++++++++++- src/devices/razer/protocol.test.ts | 153 ++++++++++++++++++ src/devices/razer/protocol.ts | 145 +++++++++++++++++ 6 files changed, 859 insertions(+), 21 deletions(-) create mode 100644 src/devices/razer/hid.test.ts diff --git a/src/devices/mouse-types.ts b/src/devices/mouse-types.ts index 5bb4b5ed..6f528b38 100644 --- a/src/devices/mouse-types.ts +++ b/src/devices/mouse-types.ts @@ -89,6 +89,22 @@ export interface MouseStatus { liftOffDistance: "Low" | "Medium" | "High" | null; /** Explicit LOD choices when a mouse does not support all three common levels. */ supportedLiftOffDistances?: Array>; + /** + * Separate cut-off and re-engage heights, where a mouse offers them instead + * of the single three-stop `liftOffDistance`. Only drivers that have verified + * it on hardware populate this; everywhere else it stays undefined and the + * control does not appear. + * + * `enabled` is which of the two the mouse is actually using, and may be null + * when a driver cannot establish it. + */ + asymmetricLiftOff?: { + enabled: boolean | null; + liftOff: number; + landing: number; + liftOffRange: { min: number; max: number }; + landingRange: { min: number; max: number }; + } | null; gamingSurfaceMode?: "On" | "Off" | "Auto" | null; lightforceSwitchMode?: "Hybrid" | "Optical" | null; firmware: string[]; diff --git a/src/devices/razer/TESTING.md b/src/devices/razer/TESTING.md index dab27605..2f88c000 100644 --- a/src/devices/razer/TESTING.md +++ b/src/devices/razer/TESTING.md @@ -25,8 +25,9 @@ The cable and the receiver are separate devices with separate product IDs, so each needs its own browser permission. Granting one does not grant the other, and switching between them the first time means adding the device again. -DPI and polling rate can be written. Every other control is withheld because no -command for it has been confirmed. +DPI and polling rate can be written. Every other control is withheld: for most +of them no command has been confirmed, and for lift-off the command is confirmed +but has nowhere to render yet — see below. 1. Connect the mouse over the cable and confirm the model, wired state, battery, charging state, DPI, and polling rate are correct. @@ -39,11 +40,25 @@ command for it has been confirmed. 5. Change the polling rate on each connection and confirm it persists. The cable offers 125/500/1000 and the receiver adds 2000/4000/8000; no other rate should appear. -6. Confirm no lift-off distance buttons and no sensor processing card appear. -7. Leave the panel open for a few minutes and confirm the background refresh - keeps reporting without stalling or throwing. -8. Record the device identifier, firmware version, and any failing setting in the - issue or pull request. +6. On the receiver, confirm the lift-off card shows a **Single / Asymmetric** + switch. On Single, the three-stop control should match what the vendor + software reports, and each stop should be distinguishable by lifting the + mouse. On Asymmetric, lift-off and landing take 2–26 and 1–25; set 26/25 and + 2/1 and confirm the difference is obvious. +7. Confirm switching back to Single restores the three-stop behaviour — the + mouse has no mode flag, so this only works because writing a tracking level + is what leaves asymmetric mode. +8. Set a landing above the lift-off and confirm it is capped rather than + rejected, and that no sensor processing card appears. +9. On the **cable**, confirm the lift-off card is absent rather than broken. + Class `0x0b` has never been exercised over USB, and the driver hides the + control instead of failing the whole status read if it is unsupported. +10. Leave the panel open for a few minutes and confirm the background refresh + keeps reporting without stalling or throwing. The mode probe is a write and + must run **once** — if the lift-off pair drifts while the panel idles, the + probe is being repeated and that is a bug. +11. Record the device identifier, firmware version, and any failing setting in + the issue or pull request. ## Verified against firmware 1.12 @@ -90,20 +105,145 @@ it as a percentage gives the wrong number. ## Lift-off distance -Not found. Class `0x0b` answers at `0x80`, `0x85`, `0x8b`, `0x8e`, `0x90`–`0x92`, -`0x94`, `0x95` and `0xa4`, and class `0x04` holds only DPI commands, but none -carries the values the vendor software shows. `0x0b`/`0x85` tracks the -asymmetric cut-off toggle in its third byte: `01` symmetric, `02` asymmetric. +**Found and confirmed on hardware.** The earlier note in this file — that +`0x0b`/`0x85` byte[2] is the asymmetric toggle, `01` symmetric / `02` asymmetric +— was wrong. Byte[2] is the symmetric tracking level. -The vendor software exposes lift-off as a continuous slider, and asymmetric mode -splits it into separate lift-off and landing values where landing cannot exceed -lift-off. That does not fit the three-value `liftOffDistance` field, so this -needs a richer type before it can be exposed even once the command is found. +| | Class / ID | dataSize | Payload | +| --- | --- | --- | --- | +| Read all | `0x0b` / `0x85` | `0x05` | `echo, 00, tracking, liftOff-1, landing-1` | +| Write pair | `0x0b` / `0x05` | `0x0a` | `00, 04, liftOff-1, landing-1, 00 × 6` | +| Write setting | `0x0b` / `0x0b` | `0x04` | `00, 04, setting, value` | +| | | | `setting 01` — tracking level `00`/`01`/`02` | +| | | | `setting 04` — asymmetric unlock, value `01` | + +Both asymmetric levels are stored **one below** the number Synapse displays. +Tracking is `0`=Low, `1`=Medium, `2`=High. + +`0x0b` / `0x03` (`dataSize 0x03`, args `00 04 01`) also exists and is accepted, +but every value `00`–`03` was tried and it changes nothing readable. The vendor +software sends it before each `0x0b`/`0x0b`, so it looks like a select or begin +step that the mouse does not actually require. Do not spend time on it. + +### The mode is last-write-wins, and there is no mode bit + +Nothing readable reports which mode is active — two 47-command captures +differing only by the checkbox were byte-identical, and a sweep of classes +`0x00`–`0x0b` across ids `0x80`–`0xff` found nothing. There is no flag to clear +either: `0x0b`/`0x0b` with setting `04` value `00` is accepted and does nothing. + +The mouse honours whichever store was written last: + +| Written | Result | +| --- | --- | +| setting `01` (tracking level) | symmetric — the pair write is then refused `0x03` | +| setting `04` value `01`, then the pair | asymmetric — the pair drives the sensor | + +**So the driver never needs to detect or toggle a mode.** It writes whichever +control the user touched. `razerSetTrackingDistanceCommand` covers the first +row; `setLiftOff` sends the unlock and then the pair for the second. + +A side effect worth knowing: because the pair write is refused in symmetric mode +and accepted in asymmetric mode, its status is an accurate report of the current +mode. `RazerHidClient.probeAsymmetric` uses this, re-sending the values the +mirror already holds so the probe disturbs nothing — a refusal cannot switch the +mode, and an acceptance re-selects the mode the mouse was already in. + +**It is still a write.** Call it once per connection, never on a background +refresh. It returns null rather than guessing when the mirror holds an inverted +pair, which the firmware permits and one session actually produced. + +Verified on hardware by repeating the probe, which is self-checking: if a probe +changed the mode, the next one would report the other answer. Nine consecutive +probes in asymmetric mode returned `0x02`, three in symmetric mode returned +`0x03`, and the read-back was unchanged throughout. A refused probe cannot +switch the mouse to asymmetric, and an accepted one re-selects what it already +was. + +Synapse presents these as two controls behind one checkbox. With **Enable +Asymmetric Cut-off** off it shows a single three-stop slider, TRACKING DISTANCE; +with it on, two 26-step sliders, LIFT-OFF (2–26) and LANDING (1–25). The two +stores are independent — switching modes preserves both. + +**The write does not mirror the read.** It carries a constant `04` in its second +argument and no echo byte, so it is offset by one from the read's layout. Three +attempts that assumed a mirror were each answered `0x03`, and each still +disturbed the stored values — **a non-ok status on this command is not a +no-op.** The working format was transcribed from a capture of the vendor +software's own packet, not inferred. + +**Read-back is not sufficient proof here.** Writes that the mouse rejected still +changed what `0x0b`/`0x85` reported, so the read-back agreed while the sensor +did not move. The format above was accepted with status `0x02` and confirmed by +lifting the mouse: 2/1 versus 26/25 is an unmistakable difference. + +The firmware also stores an inverted pair without complaint — lift-off 2 with +landing 26 round-tripped, which Synapse cannot express. `razerSetLiftOffCommand` +rejects `landing >= liftOff` before the packet is sent, because nothing +downstream will catch it. + +### Why the unlock is not optional + +Measured with byte-identical pair-write packets and only the mode changed: + +| Mode | Status | Mirror at `0x0b`/`0x85` | Sensor | +| --- | --- | --- | --- | +| Asymmetric | `0x02` ok | moved | moved, correct direction | +| Symmetric | `0x03` failure | moved anyway | **did not move** | + +The sensor half was confirmed by making the two stores disagree: tracking set to +High, the pair left at its minimum of 2/1, symmetric — the cutoff stayed high. + +**A rejected write still moves the mirror**, which is the trap. Omit the unlock +and `setLiftOff` throws, the panel shows an error, and the next background +refresh reads back *exactly the pair the user asked for* — new numbers on screen +over a sensor holding the old ones. Whatever renders this pair must not treat a +successful read as confirmation that a write landed. + +`setLiftOff` therefore sends `razerEnableAsymmetricLiftOffCommand` before every +pair write, and builds the pair command first so an out-of-range value costs no +device traffic and cannot switch the mode over a write that never happens. + +### What the client exposes + +`readLiftOff`, `setLiftOff` and `setTrackingDistance` are implemented and +hardware-verified; nothing in the interface calls them yet. + +`readLiftOff` returns null rather than throwing when the command is not +answered. Class `0x0b` has only ever been exercised on the receiver, and a +status read that throws takes the whole panel down instead of one control. + +`setLiftOff` caps landing at `liftOff - 1` instead of rejecting the pair, +because lowering lift-off past an already-set landing is ordinary use of two +controls and the vendor software caps its own slider the same way. It returns +what the mouse ended up holding. The read-back afterwards compares only the +pair — tracking was never part of the write. + +`setLiftOffDistance` writes byte[2] and is named for the shell's driver contract +rather than the vendor's wording — the vendor calls it the tracking distance. +All three stops were confirmed distinguishable at the sensor, so +`MouseStatus.liftOffDistance` carries them with no type change. + +The asymmetric pair goes in `MouseStatus.asymmetricLiftOff`, a new optional +field only drivers that have verified it populate. Everywhere else it stays +undefined and the mode switch does not appear, so no other driver changes. + +`supportedLiftOffDistances` is empty when class `0x0b` does not answer, which +hides the whole sensor card — the correct behaviour on a transport that has +never been tested against this command. ## Unresolved -- No lift-off distance command has been found, so no lift-off control is - offered and `supportedLiftOffDistances` stays empty. +- **Nothing readable reports which lift-off mode is active**, and no longer + needs to — the mouse is last-write-wins and the driver writes the mode it + wants. Recorded so the search is not repeated: two 47-command captures + differing only by the checkbox were byte-identical apart from known noise, and + a sweep of every class `0x00`–`0x0b` across ids `0x80`–`0xff` found nothing. +- Whether class `0x0b` answers at all over the cable (PID `0x00c0`). Everything + above is the receiver. `readLiftOff` degrades to null for this reason. +- Classes `0x0c`–`0x0f` have never been swept. **`0x0c`/`0x80` drops the USB + connection** — two independent sweeps died on exactly that command — so a + sweep of that region has to start at `0x81`. - No sensor processing commands (motion sync, angle snapping, ripple control) have been found, so that card stays hidden. The vendor software does not expose them for this model either, so they are more likely absent from the diff --git a/src/devices/razer/hid.test.ts b/src/devices/razer/hid.test.ts new file mode 100644 index 00000000..cd53eba7 --- /dev/null +++ b/src/devices/razer/hid.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// `hid.ts` schedules its inter-exchange delay through `window`, which node does +// not provide. The global carries the same `setTimeout`. +Object.assign(globalThis, { window: globalThis }); + +const { RazerHidClient } = await import("./hid.ts"); +const { + RAZER_PACKET_LENGTH, + RAZER_STATUS, + RAZER_TRANSACTION_ID, + razerChecksum, +} = await import("./protocol.ts"); + +interface FakeLiftOff { + tracking: number; + liftOff: number; + landing: number; + /** The mouse refuses the pair write unless this is on. */ + asymmetric: boolean; +} + +interface FakeOptions { + /** Answer class 0x0b as unsupported, the way the cable may. */ + liftOffUnsupported?: boolean; + /** Accept the write and keep the old values, as a rejected write does. */ + ignoreWrites?: boolean; +} + +function replyPacket(commandClass: number, commandId: number, dataSize: number, args: number[], status: number): Uint8Array { + const packet = new Uint8Array(RAZER_PACKET_LENGTH); + packet[0] = status; + packet[1] = RAZER_TRANSACTION_ID; + packet[5] = dataSize; + packet[6] = commandClass; + packet[7] = commandId; + packet.set(args, 8); + packet[88] = razerChecksum(packet); + return packet; +} + +/** + * A mouse that answers only the lift-off commands, storing the pair the way the + * real one does: the write carries `00 04` before the levels, and each level is + * held one below the number the vendor software shows. + */ +function fakeMouse(state: FakeLiftOff, options: FakeOptions = {}) { + const sent: Uint8Array[] = []; + let pending = new Uint8Array(RAZER_PACKET_LENGTH); + const device = { + vendorId: 0x1532, + productId: 0x00c1, + productName: "Razer Viper V3 Pro", + opened: true, + collections: [{ usagePage: 0x01, usage: 0x02, children: [], featureReports: [], inputReports: [], outputReports: [] }], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (_reportId: number, data: Uint8Array) => { + sent.push(data); + const [commandClass, commandId] = [data[6], data[7]]; + if (options.liftOffUnsupported) { + pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.unsupported); + return; + } + // Matched on class as well as id: polling shares both ids on class 0x00, + // so an id-only match would let a later test mutate the pair by accident. + const liftOffWrite = commandClass === 0x0b && commandId === 0x05; + const liftOffRead = commandClass === 0x0b && commandId === 0x85; + const settingWrite = commandClass === 0x0b && commandId === 0x0b; + // The real mouse refuses the pair write unless asymmetric mode is on, and + // leaves it again whenever a tracking level is written. + if (settingWrite && data[10] === 0x04) state.asymmetric = data[11] === 0x01; + if (settingWrite && data[10] === 0x01 && !options.ignoreWrites) { + state.tracking = data[11]; + state.asymmetric = false; + } + if (liftOffWrite && !state.asymmetric) { + pending = replyPacket(commandClass, commandId, data[5], [], RAZER_STATUS.failure); + return; + } + if (liftOffWrite && !options.ignoreWrites) { + state.liftOff = data[10] + 1; + state.landing = data[11] + 1; + } + pending = liftOffRead + ? replyPacket(commandClass, commandId, 0x05, [0, 0, state.tracking, state.liftOff - 1, state.landing - 1], RAZER_STATUS.ok) + : replyPacket(commandClass, commandId, data[5], [...data.slice(8, 8 + data[5])], RAZER_STATUS.ok); + }, + receiveFeatureReport: async () => new DataView(pending.buffer.slice(0)), + } as unknown as HIDDevice; + return { client: new RazerHidClient(device), sent }; +} + +test("lift-off reads the tracking level and the asymmetric pair together", async () => { + // Arrange + const { client } = fakeMouse({ tracking: 1, liftOff: 16, landing: 11, asymmetric: true }); + + // Act + const liftOff = await client.readLiftOff(); + + // Assert + assert.deepEqual(liftOff, { tracking: "Medium", liftOff: 16, landing: 11 }); +}); + +test("a transport that rejects class 0x0b degrades to null instead of throwing", async () => { + // A status read that throws takes the whole panel down, and the cable has + // never been checked against this command. + // Arrange + const { client } = fakeMouse({ tracking: 1, liftOff: 16, landing: 11, asymmetric: true }, { liftOffUnsupported: true }); + + // Act + const liftOff = await client.readLiftOff(); + + // Assert + assert.equal(liftOff, null); +}); + +test("the lift-off write unlocks asymmetric mode first, then uses the captured format", async () => { + // Without the unlock the mouse answers 0x03 and still moves what the read + // reports, so the omission would look like success from every angle but the + // sensor. + // Arrange + const { client, sent } = fakeMouse({ tracking: 2, liftOff: 26, landing: 25, asymmetric: false }); + + // Act + const confirmed = await client.setLiftOff(16, 11); + + // Assert + const [unlock, write] = sent; + assert.deepEqual([unlock[5], unlock[6], unlock[7]], [0x04, 0x0b, 0x0b]); + assert.deepEqual([...unlock.slice(8, 12)], [0x00, 0x04, 0x04, 0x01]); + assert.deepEqual([write[5], write[6], write[7]], [0x0a, 0x0b, 0x05]); + assert.deepEqual([...write.slice(8, 18)], [0x00, 0x04, 0x0f, 0x0a, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(confirmed, { tracking: "High", liftOff: 16, landing: 11 }); +}); + +test("landing is capped below lift-off rather than rejected", async () => { + // Lowering lift-off past an already-set landing is ordinary use of a pair of + // controls; the protocol layer would throw on the inverted pair. + // Arrange + const { client, sent } = fakeMouse({ tracking: 0, liftOff: 16, landing: 11, asymmetric: true }); + + // Act + const confirmed = await client.setLiftOff(5, 11); + + // Assert + assert.deepEqual([...sent[1].slice(8, 12)], [0x00, 0x04, 0x04, 0x03]); + assert.deepEqual(confirmed, { tracking: "Low", liftOff: 5, landing: 4 }); +}); + +test("setting a tracking distance returns the mouse to symmetric mode", async () => { + // There is no mode flag to clear — the mouse honours whichever store was + // written last, so a later pair write must be refused until it re-unlocks. + // Arrange + const state = { tracking: 0, liftOff: 16, landing: 11, asymmetric: true }; + const { client, sent } = fakeMouse(state); + + // Act + const confirmed = await client.setLiftOffDistance("High"); + + // Assert + assert.deepEqual([...sent[0].slice(8, 12)], [0x00, 0x04, 0x01, 0x02]); + assert.equal(confirmed, "High"); + assert.equal(state.asymmetric, false); +}); + +test("a tracking distance the mouse does not take is reported", async () => { + // Arrange + const { client } = fakeMouse({ tracking: 0, liftOff: 16, landing: 11, asymmetric: false }, { ignoreWrites: true }); + + // Act / Assert + await assert.rejects(() => client.setLiftOffDistance("High"), /kept Low tracking distance instead of High/); +}); + +test("the mode probe reports asymmetric from the pair write's status", async () => { + // Nothing readable carries the mode; the pair write is refused in symmetric + // mode and accepted in asymmetric, which is the only signal available. + // Arrange + const asymmetric = fakeMouse({ tracking: 1, liftOff: 16, landing: 11, asymmetric: true }); + const symmetric = fakeMouse({ tracking: 1, liftOff: 16, landing: 11, asymmetric: false }); + + // Act / Assert + assert.equal(await asymmetric.client.probeAsymmetric({ tracking: "Medium", liftOff: 16, landing: 11 }), true); + assert.equal(await symmetric.client.probeAsymmetric({ tracking: "Medium", liftOff: 16, landing: 11 }), false); +}); + +test("the mode probe leaves the stored pair exactly as it found it", async () => { + // It is a write, so it has to be one the mouse cannot act on. Re-sending the + // mirror's own values is inert whichever way the status goes. + // Arrange + const state = { tracking: 1, liftOff: 16, landing: 11, asymmetric: true }; + const { client } = fakeMouse(state); + + // Act + await client.probeAsymmetric({ tracking: "Medium", liftOff: 16, landing: 11 }); + + // Assert + assert.deepEqual(state, { tracking: 1, liftOff: 16, landing: 11, asymmetric: true }); +}); + +test("the mode probe reports unknown rather than throwing on an inverted pair", async () => { + // The firmware stores lift-off 2 with landing 26 without complaint, and one + // session left it there. Probing with that would throw in the command builder. + // Arrange + const { client, sent } = fakeMouse({ tracking: 1, liftOff: 2, landing: 26, asymmetric: true }); + + // Act + const mode = await client.probeAsymmetric({ tracking: "Medium", liftOff: 2, landing: 26 }); + + // Assert + assert.equal(mode, null); + assert.equal(sent.length, 0); +}); + +test("a level outside the three the slider offers still produces a readable error", async () => { + // `decodeLiftOff` reports an unrecognised byte[2] as null rather than guessing, + // so the mismatch message has to survive that without printing "null". + // Arrange + const { client } = fakeMouse({ tracking: 7, liftOff: 16, landing: 11, asymmetric: false }, { ignoreWrites: true }); + + // Act / Assert + await assert.rejects( + () => client.setLiftOffDistance("Medium"), + /kept an unknown tracking distance instead of Medium/, + ); +}); + +test("a write the mouse does not honour is reported instead of assumed", async () => { + // Arrange + const { client } = fakeMouse({ tracking: 0, liftOff: 26, landing: 25, asymmetric: true }, { ignoreWrites: true }); + + // Act / Assert + await assert.rejects(() => client.setLiftOff(16, 11), /kept lift-off 26 and landing 25/); +}); + +test("an out-of-range lift-off reports itself rather than the landing it drags down", async () => { + // Arrange + const { client, sent } = fakeMouse({ tracking: 0, liftOff: 16, landing: 11, asymmetric: false }); + + // Act / Assert + await assert.rejects(() => client.setLiftOff(1, 1), /Lift-off must be/); + // Nothing reached the device, so a rejected value cannot leave the mouse + // switched into asymmetric mode over a write that never happened. + assert.equal(sent.length, 0); + await assert.rejects(() => client.setLiftOff(27, 25), /Lift-off must be/); +}); diff --git a/src/devices/razer/hid.ts b/src/devices/razer/hid.ts index 5d458600..c6860691 100644 --- a/src/devices/razer/hid.ts +++ b/src/devices/razer/hid.ts @@ -1,9 +1,14 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; import { + RAZER_LANDING_MAX, + RAZER_LANDING_MIN, + RAZER_LIFT_OFF_MAX, + RAZER_LIFT_OFF_MIN, RAZER_READ, RAZER_REPORT_ID, RAZER_STATUS, + RAZER_TRACKING_DISTANCES, RazerProtocolError, decodeBatteryPercent, decodeCharging, @@ -11,13 +16,20 @@ import { decodeExtendedPollingRate, decodeFirmwareVersion, decodeLegacyPollingRate, + decodeLiftOff, decodeRazerResponse, decodeSerial, encodeRazerRequest, razerSetDpiCommand, razerSetExtendedPollingCommand, razerSetLegacyPollingCommand, + razerMaxLanding, + razerSetLiftOffCommand, + razerSetTrackingDistanceCommand, + razerEnableAsymmetricLiftOffCommand, type RazerCommand, + type RazerLiftOff, + type RazerTrackingDistance, } from "./protocol.ts"; interface RazerProduct { @@ -56,6 +68,11 @@ function isControlInterface(device: HIDDevice): boolean { export class RazerHidClient { private queue: Promise = Promise.resolve(); private readonly staticReads = new Map>(); + // The mode probe is a write, so it runs once per connection rather than on + // every background refresh. Both setters know which mode they leave behind, + // so nothing after the first read needs to ask the mouse again. + private asymmetric: boolean | null = null; + private asymmetricKnown = false; readonly device: HIDDevice; @@ -79,6 +96,7 @@ export class RazerHidClient { async close(): Promise { this.staticReads.clear(); + this.asymmetricKnown = false; if (this.device.opened) await this.device.close(); } @@ -116,6 +134,7 @@ export class RazerHidClient { const charging = decodeCharging(await this.request(RAZER_READ.charging)); const dpi = decodeDpi(await this.request(RAZER_READ.dpi)); const pollingRateHz = await this.readPollingRateHz(); + const liftOff = await this.readLiftOff(); return { brand: "Razer", name: this.displayName(), @@ -124,8 +143,10 @@ export class RazerHidClient { settingsReady: true, valuesVerified: true, hideUnsupportedPollingRates: true, - // No lift-off or sensor-processing command is confirmed, so neither - // control is offered rather than offered and left inert. + // No sensor-processing command is confirmed, so that card is hidden + // rather than offered and left inert. Lift-off is confirmed and + // readable through `readLiftOff`, but `MouseStatus` cannot carry the + // asymmetric pair yet — see the note on `liftOffDistance` below. hideProcessingCard: true, forceShowBattery: true, defaultDisplayName: this.profile()?.model, @@ -141,8 +162,17 @@ export class RazerHidClient { connectionType: wireless ? "Wireless" : "Wired", connectionDetail: wireless ? "HyperSpeed receiver" : "Wired USB", unitId: serial ? decodeSerial(serial) : null, - liftOffDistance: null, - supportedLiftOffDistances: [], + liftOffDistance: liftOff?.tracking ?? null, + // An empty list hides the control, which is what should happen on a + // transport that does not answer class 0x0b at all. + supportedLiftOffDistances: liftOff ? [...RAZER_TRACKING_DISTANCES] : [], + asymmetricLiftOff: liftOff && { + enabled: await this.asymmetricMode(liftOff), + liftOff: liftOff.liftOff, + landing: liftOff.landing, + liftOffRange: { min: RAZER_LIFT_OFF_MIN, max: RAZER_LIFT_OFF_MAX }, + landingRange: { min: RAZER_LANDING_MIN, max: RAZER_LANDING_MAX }, + }, firmware: [`Mouse ${decodeFirmwareVersion(firmware)}`], }; } @@ -176,6 +206,113 @@ export class RazerHidClient { return confirmed; } + /** + * Reads the tracking level and the asymmetric lift-off/landing pair. + * + * Returns null when the mouse does not answer. Class `0x0b` has only ever + * been exercised on the receiver, so the cable may reject it, and a status + * read that throws takes the whole panel down rather than one control. The + * caller degrades instead. + */ + async readLiftOff(): Promise { + const reply = await this.request(RAZER_READ.liftOff).catch(() => null); + return reply ? decodeLiftOff(reply) : null; + } + + /** + * Reports whether the mouse is currently in asymmetric mode, or null when it + * cannot be established. + * + * Nothing readable carries the mode — two 47-command captures differing only + * by it were byte-identical, and a full sweep found nothing. What does report + * it is the pair write's own status: refused `0x03` in symmetric mode, + * accepted `0x02` in asymmetric. + * + * That makes this a probe rather than a read, so it is written to disturb + * nothing. Re-sending the values the mirror already holds is value-preserving + * either way, and mode-preserving too: a refusal cannot switch the mode, and + * an acceptance re-selects the mode the mouse was already in. + * + * Call it once per connection, not on every refresh — it is still a write. + */ + async probeAsymmetric(current: RazerLiftOff): Promise { + // The firmware stores an inverted pair without complaint, and one session + // left it holding lift-off 2 with landing 26. Probing with that would throw + // in the command builder, so report "unknown" rather than guessing. + if (current.landing >= current.liftOff) return null; + try { + await this.request(razerSetLiftOffCommand(current.liftOff, current.landing)); + return true; + } catch (error) { + if (error instanceof RazerProtocolError && error.status === RAZER_STATUS.failure) return false; + return null; + } + } + + /** Probes once, then trusts what the setters leave behind. */ + private async asymmetricMode(current: RazerLiftOff): Promise { + if (!this.asymmetricKnown) { + this.asymmetric = await this.probeAsymmetric(current); + this.asymmetricKnown = true; + } + return this.asymmetric; + } + + /** + * Selects the symmetric tracking distance, which also takes the mouse out of + * asymmetric mode — it honours whichever store was written last, and there is + * no mode flag to clear. + * + * Named for the shell's driver contract rather than the vendor's wording; the + * vendor calls this the tracking distance. + */ + async setLiftOffDistance(distance: RazerTrackingDistance): Promise { + await this.request(razerSetTrackingDistanceCommand(distance)); + const confirmed = decodeLiftOff(await this.request(RAZER_READ.liftOff)); + if (confirmed.tracking !== distance) { + throw new Error(`The mouse kept ${confirmed.tracking ?? "an unknown"} tracking distance instead of ${distance}.`); + } + this.asymmetric = false; + this.asymmetricKnown = true; + return distance; + } + + /** + * Writes the asymmetric pair, and switches the mouse into asymmetric mode to + * do it. The tracking level shares the same reply but not the same write, so + * it is left as the mouse holds it. + * + * Landing is capped just below lift-off rather than rejected, because + * lowering lift-off past an already-set landing is ordinary use of a pair of + * controls and the vendor software caps its own slider the same way. The + * returned values are what the mouse ended up holding, not what was asked + * for, so a caller can render the cap rather than guess at it. + */ + async setLiftOff(liftOff: number, landing: number): Promise { + const capped = Math.min(landing, razerMaxLanding(liftOff)); + // Built before anything is sent, so a rejected value costs no device + // traffic and cannot leave the mouse switched into asymmetric mode over a + // write that never happened. `razerSetLiftOffCommand` validates lift-off + // before landing, so an out-of-range lift-off still reports itself rather + // than being masked by the landing it drags out of range here. + const command = razerSetLiftOffCommand(liftOff, capped); + // The pair write is refused in symmetric mode — and refused while still + // moving what the read reports, so skipping this yields a driver that looks + // like it works and never reaches the sensor. + await this.request(razerEnableAsymmetricLiftOffCommand()); + await this.request(command); + // Rejected writes on this command still disturb the stored pair, so the + // read-back is a genuine check, not a formality. Tracking is excluded — it + // was never part of the write. + const confirmed = decodeLiftOff(await this.request(RAZER_READ.liftOff)); + if (confirmed.liftOff !== liftOff || confirmed.landing !== capped) { + throw new Error(`The mouse kept lift-off ${confirmed.liftOff} and landing ${confirmed.landing} instead of ${liftOff} and ${capped}.`); + } + this.asymmetric = true; + this.asymmetricKnown = true; + return confirmed; + } + /** * Wired answers only the legacy command and the receiver only the extended * one, so ask for the expected one first and keep the other as a fallback. diff --git a/src/devices/razer/protocol.test.ts b/src/devices/razer/protocol.test.ts index 96013f0f..7670e432 100644 --- a/src/devices/razer/protocol.test.ts +++ b/src/devices/razer/protocol.test.ts @@ -2,6 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + RAZER_LANDING_MAX, + RAZER_LANDING_MIN, + RAZER_LIFT_OFF_MAX, + RAZER_LIFT_OFF_MIN, RAZER_PACKET_LENGTH, RAZER_READ, RAZER_STATUS, @@ -15,6 +19,7 @@ import { decodeExtendedPollingRate, decodeFirmwareVersion, decodeLegacyPollingRate, + decodeLiftOff, decodeRazerResponse, decodeSerial, encodeRazerRequest, @@ -22,6 +27,10 @@ import { razerSetDpiCommand, razerSetExtendedPollingCommand, razerSetLegacyPollingCommand, + razerMaxLanding, + razerSetLiftOffCommand, + razerSetTrackingDistanceCommand, + razerEnableAsymmetricLiftOffCommand, } from "./protocol.ts"; /** @@ -209,6 +218,150 @@ test("extended polling ignores the echoed request argument", () => { assert.equal(decodeExtendedPollingRate(asZero), decodeExtendedPollingRate(asOne)); }); +/* + * Lift-off fixtures are receiver captures (PID 0x00c1, firmware 1.12) taken + * while reading each Synapse setting back. Checksums are re-derived; the byte + * values are what the mouse returned. + */ +test("the asymmetric pair decodes one below the numbers Synapse shows", () => { + const low = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 00 00 00 04 03"), RAZER_READ.liftOff); + const middle = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 00 00 00 0f 0a"), RAZER_READ.liftOff); + const high = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 00 00 00 19 18"), RAZER_READ.liftOff); + + assert.deepEqual(decodeLiftOff(low), { tracking: "Low", liftOff: 5, landing: 4 }); + assert.deepEqual(decodeLiftOff(middle), { tracking: "Low", liftOff: 16, landing: 11 }); + assert.deepEqual(decodeLiftOff(high), { tracking: "Low", liftOff: 26, landing: 25 }); +}); + +test("the symmetric level decodes from its own byte, leaving the pair alone", () => { + // The same 26/25 pair at each of the three tracking stops. Moving the + // symmetric slider never touched bytes 3 and 4, which is what makes these two + // controls separate stored settings rather than two views of one. + const stops = ["00 00 00 19 18", "00 00 01 19 18", "00 00 02 19 18"]; + const decoded = stops.map((args) => decodeLiftOff( + decodeRazerResponse(reply(`02 1f 00 00 00 05 0b 85 ${args}`), RAZER_READ.liftOff), + )); + + assert.deepEqual(decoded.map((value) => value.tracking), ["Low", "Medium", "High"]); + assert.deepEqual(decoded.map((value) => value.liftOff), [26, 26, 26]); + assert.deepEqual(decoded.map((value) => value.landing), [25, 25, 25]); +}); + +test("lift-off ignores the echoed request argument", () => { + // Byte[0] came back holding whatever the request carried. Arguments 0x00 + // through 0x07 all returned the same levels, so it selects nothing. + const asZero = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 00 00 00 0f 0a"), RAZER_READ.liftOff); + const asThree = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 03 00 00 0f 0a"), RAZER_READ.liftOff); + + assert.deepEqual(decodeLiftOff(asZero), decodeLiftOff(asThree)); +}); + +test("a tracking level the slider does not offer decodes as null", () => { + // Three stops are all Synapse exposes, so a fourth value is a reading this + // driver does not understand. Reporting null hides the control instead of + // showing a level the mouse is not holding. + const unexpected = decodeRazerResponse(reply("02 1f 00 00 00 05 0b 85 00 00 07 19 18"), RAZER_READ.liftOff); + + assert.equal(decodeLiftOff(unexpected).tracking, null); +}); + +test("every level the sliders offer stays inside the documented bounds", () => { + // The encoding is zero-based, so the extremes are what prove the bounds are + // the sliders' and not an artefact of the offset. + const lowest = decodeLiftOff(new Uint8Array([0, 0, 0, RAZER_LIFT_OFF_MIN - 1, RAZER_LANDING_MIN - 1])); + const highest = decodeLiftOff(new Uint8Array([0, 0, 2, RAZER_LIFT_OFF_MAX - 1, RAZER_LANDING_MAX - 1])); + + assert.deepEqual(lowest, { tracking: "Low", liftOff: 2, landing: 1 }); + assert.deepEqual(highest, { tracking: "High", liftOff: 26, landing: 25 }); + assert.ok(lowest.landing < lowest.liftOff); + assert.ok(highest.landing < highest.liftOff); +}); + +test("a lift-off write matches the packet the vendor software sends", () => { + // Transcribed from a capture of Synapse's own traffic: dataSize 0x0a, then + // `00 04` before the pair. Not inferred from the read — the read's layout is + // different, and assuming otherwise produced three rejected writes. + const packet = encodeRazerRequest(razerSetLiftOffCommand(16, 11)); + + assert.equal(packet[5], 0x0a); + assert.equal(packet[6], 0x0b); + assert.equal(packet[7], 0x05); + assert.deepEqual([...packet.slice(8, 18)], [0x00, 0x04, 0x0f, 0x0a, 0, 0, 0, 0, 0, 0]); + assert.equal(packet[88], razerChecksum(packet)); +}); + +test("a lift-off write clears the high bit of the matching read", () => { + assert.equal(RAZER_WRITE.liftOff.commandClass, RAZER_READ.liftOff.commandClass); + assert.equal(RAZER_WRITE.liftOff.commandId, RAZER_READ.liftOff.commandId & 0x7f); +}); + +test("lift-off writes round-trip through the decoder at both extremes", () => { + // The write payload is offset from the read's, so a round trip is the check + // that actually catches a mistranscribed layout. + for (const [liftOff, landing] of [[2, 1], [16, 11], [26, 25]] as const) { + const request = encodeRazerRequest(razerSetLiftOffCommand(liftOff, landing)); + // Read byte[n] carries write arg[n-1] — the read has a leading echo byte the + // write does not. Arguments start at packet[8], so the pair is [10] and [11]. + const decoded = decodeLiftOff(new Uint8Array([0, 0, 0, request[10], request[11]])); + assert.equal(decoded.liftOff, liftOff); + assert.equal(decoded.landing, landing); + } +}); + +test("an inverted pair is refused before it reaches the mouse", () => { + // The firmware stored lift-off 2 with landing 26 without complaint, which the + // vendor software cannot even express. The read-back check cannot catch this, + // because the mouse reports back exactly the invalid pair it was given. + assert.throws(() => razerSetLiftOffCommand(5, 5), RazerProtocolError); + assert.throws(() => razerSetLiftOffCommand(2, 26), RazerProtocolError); + assert.doesNotThrow(() => razerSetLiftOffCommand(5, 4)); +}); + +test("lift-off values outside the vendor sliders are refused", () => { + assert.throws(() => razerSetLiftOffCommand(1, 0), RazerProtocolError); + assert.throws(() => razerSetLiftOffCommand(27, 25), RazerProtocolError); + assert.throws(() => razerSetLiftOffCommand(16, 0), RazerProtocolError); + assert.throws(() => razerSetLiftOffCommand(16.5, 11), RazerProtocolError); +}); + +test("landing is always at least one step below lift-off", () => { + // The rule the controls enforce: lift-off 10 permits landing 9, never 10. + assert.equal(razerMaxLanding(10), 9); + // Every lift-off the sliders offer, checked against the command that would + // carry it — the ceiling has to be a pair the mouse will actually accept. + for (let liftOff = RAZER_LIFT_OFF_MIN; liftOff <= RAZER_LIFT_OFF_MAX; liftOff += 1) { + const ceiling = razerMaxLanding(liftOff); + assert.ok(ceiling < liftOff, `landing ${ceiling} is not below lift-off ${liftOff}`); + assert.ok(ceiling >= RAZER_LANDING_MIN && ceiling <= RAZER_LANDING_MAX); + assert.doesNotThrow(() => razerSetLiftOffCommand(liftOff, ceiling)); + assert.throws(() => razerSetLiftOffCommand(liftOff, ceiling + 1), RazerProtocolError); + } +}); + +test("the tracking-distance write encodes the level the read reports", () => { + // `0x0b`/`0x0b` carries `00 04` then a setting id and its value. Setting 01 is + // the tracking level, and it uses the same 0/1/2 codes as read byte[2] — this + // was confirmed on hardware by a Low to High transition, not an identity write. + for (const [distance, level] of [["Low", 0], ["Medium", 1], ["High", 2]] as const) { + const packet = encodeRazerRequest(razerSetTrackingDistanceCommand(distance)); + + assert.deepEqual([packet[5], packet[6], packet[7]], [0x04, 0x0b, 0x0b]); + assert.deepEqual([...packet.slice(8, 12)], [0x00, 0x04, 0x01, level]); + assert.equal(decodeLiftOff(new Uint8Array([0, 0, packet[11], 1, 0])).tracking, distance); + assert.equal(packet[88], razerChecksum(packet)); + } +}); + +test("the asymmetric unlock is the same command with setting 04", () => { + // Sent before every pair write. Without it the mouse answers 0x03 and still + // moves what the read reports, so its absence is invisible to a read-back. + const packet = encodeRazerRequest(razerEnableAsymmetricLiftOffCommand()); + + assert.deepEqual([packet[5], packet[6], packet[7]], [0x04, 0x0b, 0x0b]); + assert.deepEqual([...packet.slice(8, 12)], [0x00, 0x04, 0x04, 0x01]); + assert.equal(packet[88], razerChecksum(packet)); +}); + test("serial text stops at the terminator", () => { const args = decodeRazerResponse( reply("02 1f 00 00 00 16 00 82 50 4d 30 30 30 30 48 30 30 30 30 30 30 30 30 00"), diff --git a/src/devices/razer/protocol.ts b/src/devices/razer/protocol.ts index 50e62e80..673829f5 100644 --- a/src/devices/razer/protocol.ts +++ b/src/devices/razer/protocol.ts @@ -53,6 +53,7 @@ export const RAZER_READ = { dpiStages: { commandClass: 0x04, commandId: 0x86, dataSize: 0x26, args: [0x00] }, pollingRate: { commandClass: 0x00, commandId: 0x85, dataSize: 0x01 }, pollingRateExtended: { commandClass: 0x00, commandId: 0xc0, dataSize: 0x02, args: [0x00] }, + liftOff: { commandClass: 0x0b, commandId: 0x85, dataSize: 0x05 }, } as const satisfies Record; /** @@ -67,6 +68,8 @@ export const RAZER_WRITE = { dpi: { commandClass: 0x04, commandId: 0x05, dataSize: 0x07 }, pollingRate: { commandClass: 0x00, commandId: 0x05, dataSize: 0x01 }, pollingRateExtended: { commandClass: 0x00, commandId: 0x40, dataSize: 0x02 }, + liftOff: { commandClass: 0x0b, commandId: 0x05, dataSize: 0x0a }, + sensorSetting: { commandClass: 0x0b, commandId: 0x0b, dataSize: 0x04 }, } as const satisfies Record>; export function razerSetDpiCommand(x: number, y: number): RazerCommand { @@ -200,6 +203,147 @@ export function decodeDpiStages(args: Uint8Array): RazerDpiStages { return { active: args[1], stages }; } +/** + * Lift-off distance. The vendor software presents this as two different + * controls behind one checkbox, and the mouse stores both independently: + * + * - Asymmetric off: a single three-stop slider, "Tracking Distance". + * - Asymmetric on: two 26-step sliders, lift-off and landing. + * + * Switching modes leaves the other mode's stored value untouched, so neither + * reading destroys the other. + */ +export type RazerTrackingDistance = "Low" | "Medium" | "High"; + +/** Indexed by the byte the mouse reports, so the order is the encoding. */ +export const RAZER_TRACKING_DISTANCES: readonly RazerTrackingDistance[] = ["Low", "Medium", "High"]; + +/** Synapse's own numbering. Landing must stay strictly below lift-off. */ +export const RAZER_LIFT_OFF_MIN = 2; +export const RAZER_LIFT_OFF_MAX = 26; +export const RAZER_LANDING_MIN = 1; +export const RAZER_LANDING_MAX = 25; + +/** + * Second argument of every class 0x0b write the vendor software was observed + * making. It reads as a feature selector — smart tracking — and the mouse + * rejects the packet without it. + */ +const RAZER_SENSOR_SELECTOR = 0x04; + +/** + * Third argument of `0x0b`/`0x0b`, choosing which setting the fourth carries. + * Both were read off the vendor software's own traffic and confirmed on + * hardware. + */ +const RAZER_SENSOR_SETTING = { + trackingDistance: 0x01, + asymmetric: 0x04, +} as const; + +export interface RazerLiftOff { + /** Null when the mouse reports a level outside the three the slider offers. */ + tracking: RazerTrackingDistance | null; + liftOff: number; + landing: number; +} + +/** + * Both asymmetric levels are stored one below the number Synapse displays, so + * lift-off 26 is 0x19 and landing 25 is 0x18. Confirmed at 5/4, 16/11 and 26/25, + * with the middle reading predicted before it was measured. + * + * Byte[0] echoes whatever argument the request carried and byte[1] was zero in + * every capture, so neither is read here — arguments 0x00 through 0x07 all + * returned the same levels, which rules out a per-profile store. + */ +export function decodeLiftOff(args: Uint8Array): RazerLiftOff { + return { + tracking: RAZER_TRACKING_DISTANCES[args[2]] ?? null, + liftOff: args[3] + 1, + landing: args[4] + 1, + }; +} + +/** + * The highest landing a given lift-off permits: lift-off 10 allows 9, and so on + * down to the floor of 1. + * + * Landing is bounded by lift-off rather than locked to it — the vendor software + * caps its own slider the same way, and 16/11 is a perfectly ordinary pair. The + * firmware stores an inverted pair without complaint, and one session left the + * mouse holding lift-off 2 with landing 26, which the vendor software cannot + * even express. Nothing downstream catches that, so the rule is enforced here. + */ +export function razerMaxLanding(liftOff: number): number { + return Math.min(RAZER_LANDING_MAX, Math.max(RAZER_LANDING_MIN, liftOff - 1)); +} + +/** + * Selects the mouse's symmetric tracking distance, and by doing so switches it + * out of asymmetric mode. + * + * The mouse has no readable mode bit and no mode flag to set. It honours + * whichever of the two stores was written most recently, so writing a tracking + * level *is* the way to return to symmetric — confirmed by the asymmetric pair + * write being rejected immediately afterwards. + */ +export function razerSetTrackingDistanceCommand(distance: RazerTrackingDistance): RazerCommand { + const level = RAZER_TRACKING_DISTANCES.indexOf(distance); + if (level < 0) throw new RazerProtocolError(`${distance} is not a tracking distance this mouse offers.`); + return { + ...RAZER_WRITE.sensorSetting, + args: [0x00, RAZER_SENSOR_SELECTOR, RAZER_SENSOR_SETTING.trackingDistance, level], + }; +} + +/** + * Unlocks the asymmetric pair write, which the mouse rejects with status `0x03` + * in symmetric mode — and rejects while still moving what the read reports, so + * skipping this produces a driver that appears to work and never touches the + * sensor. + * + * There is no matching disable. Sending value `0x00` was accepted and changed + * nothing; the vendor software leaves asymmetric mode by writing a tracking + * level instead, which is what `razerSetTrackingDistanceCommand` does. + */ +export function razerEnableAsymmetricLiftOffCommand(): RazerCommand { + return { + ...RAZER_WRITE.sensorSetting, + args: [0x00, RAZER_SENSOR_SELECTOR, RAZER_SENSOR_SETTING.asymmetric, 0x01], + }; +} + +/** + * Lift-off write, transcribed from the vendor software's own packet rather than + * inferred: `dataSize 0x0a`, then `00 04` before the pair. It does NOT mirror + * the read's layout, which is what three earlier guesses assumed — each was + * answered `0x03` while still disturbing the stored values. + * + * Must be preceded by `razerEnableAsymmetricLiftOffCommand`. + * + * Confirmed on hardware: status `0x02`, read-back exact, and the change is + * felt at the sensor, which a read-back alone cannot establish. + */ +export function razerSetLiftOffCommand(liftOff: number, landing: number): RazerCommand { + if (!Number.isInteger(liftOff) || liftOff < RAZER_LIFT_OFF_MIN || liftOff > RAZER_LIFT_OFF_MAX) { + throw new RazerProtocolError(`Lift-off must be a whole number between ${RAZER_LIFT_OFF_MIN} and ${RAZER_LIFT_OFF_MAX}.`); + } + if (!Number.isInteger(landing) || landing < RAZER_LANDING_MIN || landing > RAZER_LANDING_MAX) { + throw new RazerProtocolError(`Landing must be a whole number between ${RAZER_LANDING_MIN} and ${RAZER_LANDING_MAX}.`); + } + // The firmware stores an inverted pair without complaint — a write of + // landing 26 against lift-off 2 round-tripped and left the mouse in a state + // the vendor software cannot express. Nothing downstream will catch this. + if (landing >= liftOff) { + throw new RazerProtocolError(`Landing (${landing}) must be below lift-off (${liftOff}).`); + } + return { + ...RAZER_WRITE.liftOff, + args: [0x00, RAZER_SENSOR_SELECTOR, liftOff - 1, landing - 1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + }; +} + /** * Legacy polling encodes the rate as a divisor of 1000, so it cannot express * the HyperPolling rates. Wireless answers this command as unsupported. @@ -218,3 +362,4 @@ export function decodeExtendedPollingRate(args: Uint8Array): number { if (!args[1]) throw new RazerProtocolError("The mouse reported an unknown polling rate."); return Math.round(8000 / args[1]); } + From a950c94b610d216fc2c595011e78b3f8a8b52bbe Mon Sep 17 00:00:00 2001 From: Pochiiko Date: Thu, 6 Aug 2026 03:26:06 +1000 Subject: [PATCH 2/3] feat(control): add lift-off mode switch and asymmetric sliders Mice that report asymmetricLiftOff get a Single / Asymmetric switch in the shape the vendor software uses: one control or the other, never both. Drivers that do not report the pair never see the switch and keep the plain three-stop control, so no existing device changes. Choosing a mode is itself the write, because the mouse honours whichever store was written last. That means there is no mode flag to drift out of sync, and no state the panel can claim while the device disagrees. Landing is a slider bounded by lift-off rather than a number field, so an invalid pair is not expressible. The value is clamped and the range is left alone: a range input positions its thumb relative to its own bounds, so narrowing max to the ceiling slid the thumb across the track whenever lift-off moved even though the number under it had not changed. Keeping both at the device's full range also lines the two tracks up, since they are the same 24 steps offset by one. Staging happens on release, so a drag is one change. Two fixes to existing CSS that this uncovered: - `.segmented.three` was never defined, so the three lift-off buttons had been laid out on a four-column grid. - Author-level `display` beats the browser's `[hidden]` rule, so a `hidden` property set from script silently did nothing on anything laid out with grid or flex. There was already a one-off patch for `.pending-bar`; this replaces the need for more of them. --- src/control-events.ts | 22 +++++++++ src/control-template.ts | 2 +- src/control.css | 14 ++++++ src/control.ts | 98 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/control-events.ts b/src/control-events.ts index ec9e999a..61337533 100644 --- a/src/control-events.ts +++ b/src/control-events.ts @@ -37,6 +37,9 @@ export interface ControlEventHandlers { applyFinalmouseSetting(setting: "dongleLed" | "tournamentScroll" | "tournamentTimeout", value: number): void; applyPollingRate(rate: number): void; applyLiftOffDistance(lod: NonNullable): void; + applyLiftOffMode(mode: "single" | "asymmetric"): void; + applyAsymmetricLiftOff(liftOff: number, landing: number): void; + capLandingToLiftOff(): void; applyGamingSurfaceMode(mode: NonNullable): void; applyLightforceSwitchMode(mode: NonNullable): void; flashPendingChanges(): Promise; @@ -180,6 +183,25 @@ export function bindControlEvents(handlers: ControlEventHandlers): void { if (lod) void handlers.applyLiftOffDistance(lod); }); }); + document.querySelectorAll("[data-lod-mode]").forEach((button) => { + button.addEventListener("click", () => { + const mode = button.dataset.lodMode; + if (mode === "single" || mode === "asymmetric") handlers.applyLiftOffMode(mode); + }); + }); + const liftOffSlider = document.querySelector("#lod-lift-off"); + const landingSlider = document.querySelector("#lod-landing"); + for (const slider of [liftOffSlider, landingSlider]) { + // `input` keeps the readout and the landing ceiling honest while dragging; + // `change` fires on release, so a drag stages one change rather than thirty. + slider?.addEventListener("input", () => handlers.capLandingToLiftOff()); + slider?.addEventListener("change", () => { + handlers.capLandingToLiftOff(); + const liftOff = Number(liftOffSlider?.value); + const landing = Number(landingSlider?.value); + if (Number.isFinite(liftOff) && Number.isFinite(landing)) handlers.applyAsymmetricLiftOff(liftOff, landing); + }); + } document.querySelectorAll("[data-gaming-surface]").forEach((button) => { button.addEventListener("click", () => { const mode = button.dataset.gamingSurface as MouseStatus["gamingSurfaceMode"]; diff --git a/src/control-template.ts b/src/control-template.ts index e711d6cb..e9386348 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -55,7 +55,7 @@ export function controlTemplate(buildLabel: string): string {

DPI

Sensitivity

Choose a DPI value

POLLING RATE

Report frequency

Higher rates update cursor movement more often, but use more battery.
-

SENSOR

Lift-off distance

Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.
+

SENSOR

Lift-off distance

Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.