From 7ba712681bc0e0d92ae7836212442b2e1a883b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Mart=C3=ADnez=20Rinc=C3=B3n?= Date: Sun, 6 Sep 2026 01:02:01 +0200 Subject: [PATCH] Add MCHOSE A7 V2 driver and MagDock lighting Adds support for the MCHOSE A7 V2 family (vendor 0x3837) and, as a second driver, the MagDock charging base that carries the family's RGB. The protocol was recovered from MCHOSE's own M HUB web driver and verified against an A7 V2 Ultra+ over both its 2.4 GHz receiver and its cable. Every setter was round-tripped on hardware and restored; docs/mchose-protocol.md records the wire format, the dead ends, and the traps. Mouse (src/mchose, src/drivers/mchose/hid.ts): - model, firmware, battery and charge state - six DPI stages with editable values and stage count - polling rate, per link - lift-off distance, motion sync, ripple control, angle snapping - performance mode (Performance / eSports / Ultra), angle tuning - debounce, auto-sleep - three onboard profiles, with the names the device stores - button remapping across mouse, keyboard, media, DPI, system and profile actions; an assigned macro is preserved and named, not clobbered MagDock (src/mchose/dock.ts, src/drivers/mchose/dock-hid.ts): - a separate device with a separate protocol: plain, non-inverted frames on unnumbered report 0. Shaped as a non-mouse status carrying only lighting. Three details are easy to get wrong and are covered by tests: - the whole command body is transmitted bit-inverted, which makes the config channel look like a loopback until you notice - every command shares one reply buffer, so a read needs both a command-echo check and a plausibility check or it can return another command's answer - MCHOSE ships two contradictory schemas for the config blob; the write schema is the correct one, established by reading the device at known settings Co-Authored-By: Claude Opus 5 --- docs/mchose-protocol.md | 446 +++++++++++++++++++ package.json | 4 + src/drivers/mchose/dock-hid.test.ts | 127 ++++++ src/drivers/mchose/dock-hid.ts | 208 +++++++++ src/drivers/mchose/hid.test.ts | 609 +++++++++++++++++++++++++ src/drivers/mchose/hid.ts | 665 ++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 27 +- src/drivers/registry.ts | 6 +- src/drivers/vendors.ts | 13 + src/mchose/buttons.test.ts | 119 +++++ src/mchose/buttons.ts | 220 +++++++++ src/mchose/dock.test.ts | 108 +++++ src/mchose/dock.ts | 156 +++++++ src/mchose/index.test.ts | 337 ++++++++++++++ src/mchose/index.ts | 562 +++++++++++++++++++++++ tsconfig.json | 1 + 16 files changed, 3606 insertions(+), 2 deletions(-) create mode 100644 docs/mchose-protocol.md create mode 100644 src/drivers/mchose/dock-hid.test.ts create mode 100644 src/drivers/mchose/dock-hid.ts create mode 100644 src/drivers/mchose/hid.test.ts create mode 100644 src/drivers/mchose/hid.ts create mode 100644 src/mchose/buttons.test.ts create mode 100644 src/mchose/buttons.ts create mode 100644 src/mchose/dock.test.ts create mode 100644 src/mchose/dock.ts create mode 100644 src/mchose/index.test.ts create mode 100644 src/mchose/index.ts diff --git a/docs/mchose-protocol.md b/docs/mchose-protocol.md new file mode 100644 index 0000000..0de7765 --- /dev/null +++ b/docs/mchose-protocol.md @@ -0,0 +1,446 @@ +# MCHOSE protocol notes + +Vendor id **`0x3837`**. Reverse-engineered from MCHOSE's own **M HUB web +driver** (`https://www.mchose.com.cn/assets/purify.es-DfJclCKp.js`, build +`0cac79355`, 2026-09-04) and verified against an **A7 V2 Ultra+** over both its +2.4 GHz receiver and its USB cable. + +## Which collection is the real one + +The mouse and its receiver both expose three interfaces and five collections. +Two of them look like control channels and are not: + +| Usage page / usage | Reports | What it is | +| --- | --- | --- | +| `0x0001` / `0x0002` | — | boot mouse. Opens, but has **no readable feature report at any id 0x01–0xff**. | +| `0x0001`/`0x0006`, `0x000c`/`0x0001` | — | keyboard + consumer control | +| `0xff0b` / `0x0104` | in `0x2a` `0x2c` `0x2d`, out `0x2a` `0x2d`, feature `0x2a` `0x2b` | **firmware update.** The offer/response/payload triple is Microsoft's Component Firmware Update shape, and M HUB drives these ids against `/newBin/.offer.bin`. Feature `0x2a` reads a firmware descriptor. Not config. | +| `0xff01` / `0x0001` | in `0x11` `0x12`, out `0x13` `0x14`, feature `0x11` `0x12` `0x14` | **the configuration channel** | + +Output report `0x13` is declared but the firmware rejects every write to it; +`0x14` accepts writes and never answers. Configuration does not use the output +reports at all — it uses **feature** reports `0x11` and `0x12`. + +## Transport: everything is bit-inverted + +This is the detail that makes the channel look like a loopback if you miss it. +M HUB's sender spells each command as a token string (`"11 06 00 00 …"`), takes +the first token as the report id, and sends **every remaining token XOR 0xff**: + +```js +sendFeatureReport(0x11, [cmd ^ 0xff, arg0 ^ 0xff, …]) // rest of the report stays 0x00 +receiveFeatureReport(0x11) -> [reportId, ~cmd, ~payload0, ~payload1, …] +``` + +The reply's byte 1 must un-invert to the command that was sent. Report `0x11` +carries the short command set (command + 19 argument bytes); report `0x12` +carries the long one (command + 63). Chrome zero-pads the rest of the 64-byte +report, and the firmware leaves stale scratch bytes past the meaningful +fields — including, at times, a leftover USB string descriptor — so a reply +must be read only up to the length its schema defines. + +**Replies are not ready immediately.** Commands that cross the RF link answer +empty at first; M HUB re-queues exactly `0x67` and `0x63` with a growing +timeout. Poll until the same bytes come back twice before believing them — a +single read routinely catches the buffer half-written. + +## Commands + +Only entries that carry an `order` string in M HUB's table are pollable +commands. The rest (`0x02`, `0x0a`, `0x0b`, `0x2b`, `0x40`–`0x43`, `0x58`) have +a parser but no order: they decode **unsolicited** reports, and polling them +returns nothing. + +| Report | Command | Payload | +| --- | --- | --- | +| `0x11` | `0x03` | `bonded` u8, `vid` u16, `pid` u16, `connected` u8, `gameMode` u8 | +| `0x11` | `0x04` | length-prefixed ASCII firmware version | +| `0x11` | `0x06` | `vid` u16, `pid` u16, `fwVersion` u32, flags (3 bits mode, 1 bit status), `batteryLevel` u8, `chargeStatus` u8 | +| `0x12` | `0x67` | the configuration blob, below | +| `0x12` | `0x68 ` | profile name: index + NUL-terminated ASCII | +| `0x11` | `0x58 ` | **write:** switch the active profile (0-based) | +| `0x11` | `0x0a ` | **write:** auto-sleep timer | +| `0x11` | `0x42 …` | **write:** lift-off, processing toggles, performance mode, angle tuning | +| `0x12` | `0x57 …` | **write:** the configuration blob, below | +| `0x12` | `0x52 …` | **write:** reassign one button, see below | +| `0x12` | `0x63 ` | button name: index, length, ASCII — a macro's name lives here | +| `0x12` | `0x65 …`, `0x55 …` | paged macro data, not decoded here | + +**Macros are readable but not writable here.** A button set to type 4 keeps its +assignment untouched, and its name is read with `0x12 0x63 ` (reply: +index, length, ASCII) so it shows as `Macro: ` rather than a bare +"Macro". Recording one needs the paged `0x12 0x65` / `0x12 0x55` data channel, +which is not implemented. The mouse's own lighting +commands go unused because this model has no LEDs — its RGB is on the charging +base, which is a separate device with a separate protocol (see the MagDock +section at the end). + +## Button remapping + +**Write:** `0x12 0x52` → `[command, buttonIndex, reserved, buttonType, +value(u24, big-endian)]`. It is standalone — verified on hardware that it moves +only the target button's four bytes and leaves DPI, polling and the other +buttons alone. + +**Read:** the same assignments are mirrored in the config blob at offsets 20-43, +six four-byte entries of `[(buttonIndex << 4) | buttonType, value(u24 BE)]`. + +Physical order is **left, middle, right, forward, back, DPI** — note this is not +the order used by the other MCHOSE protocol family, which puts right at index 1. + +The type nibble selects which table the value is looked up in, so **type and +value only mean anything together**: `0x010000` is left-click under type 1, "DPI +switch" under type 5, and "switch to profile 1" under type 10. + +| Type | Meaning | Examples | +| --- | --- | --- | +| 0 | factory default | value must be 0 — the firmware's own reset | +| 1 | mouse button | left `0x010000`, right `0x020000`, middle `0x040000`, forward `0x100000`, back `0x080000`, wheel up `0x000200`, wheel down `0x00fe00` | +| 2 | keyboard | HID usage in the middle byte: F9 `0x004200`, A `0x000400`. The top byte is a modifier mask — Ctrl `0x01`, Shift `0x02`, Alt `0x04`, Win `0x08`, so Ctrl+C is `0x010600` and Alt+Tab `0x042b00` | +| 3 | media | play/pause `0xcd0000`, next `0xb50000`, volume+ `0xe90000`, mute `0xe20000` | +| 4 | macro | value indexes the stored macro | +| 5 | DPI | switch `0x010000`, + `0x020000`, - `0x030000` | +| 8 | system | copy `0x070106`, cut `0x07011b`, paste `0x070119`, brightness `0x0c6f00` | +| 9 | disabled | `0xffffff` | +| 10 | profile | profile 1-3 `0x010000`-`0x030000`, cycle `0x040000` | + +Type 7 exists in the vendor bundle but its table is empty. + +Verified on an A7 V2 Ultra+ by remapping the forward button through a mouse +action, a media action and a keyboard key, then restoring — each read back +correctly and the rest of the config stayed byte-identical. **Only ever test on +a button you can spare**; index 0 is the left click. + +Worth knowing when reading a stock device: on the test hardware, forward and +back were **not** on defaults — they shipped mapped to keyboard F9 and F10 +(type 2), while left, middle, right and DPI were type 0. + +`0x06` reports **the mouse's own product id**, even when the host is talking to +a receiver. That is how a model is identified — see below. + +### `0x12 0x67` configuration + +| Offset | Field | +| --- | --- | +| 0 | profile index | +| 1 | **wired** link: high nibble = polling index, low nibble = DPI stage | +| 2 | **wireless** link: high nibble = polling index, low nibble = DPI stage | +| 3 | reserved — **the stage table does not start here** | +| 4 … 15 | six DPI stages, little-endian uint16 each | +| 16 | stage count | +| 17 | sensor flags | +| 18 | key debounce | +| 19 | sleep | + +Polling is a plain index into the model's rate list +(`[125, 500, 1000, 2000, 4000, 8000]` on an 8K model) with no skipped value. + +Captured from the Ultra+ on its receiver while set to 1000 Hz and 1600 DPI: + +``` +00 30 20 00 40 06 20 03 40 06 80 0c 00 19 10 a4 01 80 00 00 + ^^ wireless: rate index 2 (1000 Hz), stage 0 + ^^ wired: rate index 3 (2000 Hz), stage 0 + dpi stages: 1600 800 1600 3200 6400 42000 +``` + +> **MCHOSE ships two contradictory schemas for this payload.** Its *read* +> parser claims the wireless byte comes first with the DPI nibble high; its +> *write* schema claims the wired byte comes first with the rate nibble high. +> The write schema is the correct one, established by reading the same mouse at +> two known settings: at 125 Hz the wireless byte read `0x00`, and after +> changing to 1000 Hz it read `0x20`. Do not "fix" this to match the read +> parser. + +### `0x12 0x57` — write the configuration + +The write payload is **exactly the read payload with the command byte in +front**, which was confirmed by echoing a config back unchanged and observing +no change. So a setting is applied by reading `0x67`, altering only the target +field, and sending the whole thing back — which is what preserves button +mappings and macros that a zero-filled partial write would wipe. + +Verified on hardware: wireless polling 1000 Hz → 500 Hz took effect and read +back, then restored. + +## Profiles + +The mouse holds **three onboard profiles**, 0-based on the wire, switched with +`0x11 0x58 `. Each carries its own DPI stage table *and* its own pair of +per-link rate/stage bytes, so DPI and polling both change with the profile. + +`0x67` takes **no profile argument** — passing one is ignored and it always +answers for whichever profile is active. Reading another profile therefore means +switching to it first, which changes the mouse's behaviour as a side effect. + +The device needs roughly half a second after a switch before it answers for the +new profile; at 250 ms a read came back empty, at 600 ms all three were read +reliably. Verified on an A7 V2 Ultra+ by walking 0 → 1 → 2 and back: + +``` +profile 0: wired 0x30 wireless 0x30 (2000 Hz, stage 0) +profile 1: wired 0x32 wireless 0x22 (1000 Hz, stage 2) +profile 2: wired 0x32 wireless 0x22 (1000 Hz, stage 2) +``` + +## The shared reply buffer will bite you + +Every command on this collection answers into **one buffer**, and a read issued +before the firmware has refilled it returns *the previous command's reply*. In +testing, a config read handed back a battery payload, which was then written +back as configuration — the firmware rejected it, but nothing in the transport +prevented the attempt. + +Two guards are required on every read: + +1. the reply's command echo must un-invert to the command that was sent, and +2. the payload must be plausible for that command (a config reply always + carries a sane first DPI stage). + +Then poll until the same bytes arrive twice. The buffer also retains unrelated +data — a USB string descriptor turned up in it more than once — so never trust +bytes past the length a command's schema defines. + +## Identifying a model + +Host-facing product ids are shared across the whole A7 V2 family and identify a +*link*, not a model: + +| PID | Link | +| --- | --- | +| `0x100b` | 2.4 GHz receiver | +| `0x100a` | Bluetooth (capped at 1000 Hz) | +| `0x1020` | 8K receiver | + +The model-specific id is the mouse's own, reported inside the `0x06` reply and +used directly when the mouse is on a cable: + +| Model | Mouse PID | DPI max | LOD steps | +| --- | --- | --- | --- | +| A7 V2 Pro | `0x4018` | 26000 | 1 mm, 2 mm | +| A7 V2 Pro+ | `0x4023` | 26000 | 1 mm, 2 mm | +| A7 V2 Ultra | `0x4019` | 42000 | 0.7 mm, 1 mm, 2 mm | +| A7 V2 Ultra+ | `0x4021` | 42000 | 0.7 mm, 1 mm, 2 mm | + +MCHOSE's own firmware-version table cross-checks this: the Ultra reports +`5.44.2.4` and the Ultra+ `5.46.2.4`. The test hardware reported `5.46.2.4`. + +## Dead ends, so the next person can skip them + +- **The `0x4d`-magic framing in the same bundle is a different product line.** + It is a real MCHOSE protocol (`[4d][ver][flags][len][cmdLo][cmdHi][biz][seq]…` + plus an XOR checksum, commands `0x00xx` read / `0x01xx` write) but the A7 V2 + has no report `0x4d`, and every variant of it was rejected by the hardware. + A second framing in that bundle starting `0xaa` belongs to the keyboard and + audio paths. +- **CompX framing does not apply.** The older MCHOSE A5/AX5 line (VID `0x2023`, + reverse-engineered by [`Klegus/mchose-macos`](https://github.com/Klegus/mchose-macos) + from MCHOSE's `DriverCore.exe`) speaks CompX over usage page `0xffff` with + success byte `0xa1` — the framing already in `src/compx/codec.ts`. Different + VID, usage page and transport; CompX reads sent to an A7 V2 got nothing. +- **`node-hid` cannot use the output reports**, and the boot-mouse collection + answers no feature report at any id. Neither is a missing-permission problem; + no MCHOSE process needs to be running. +- **The mouse hub at `https://www.mchose.com.cn:9999/`** is where MCHOSE's mouse + UI lives, but it was unreachable during this work (port 443 on the same host + serves fine). Everything above came from the port-443 bundle, which carries + the mouse command table even though its UI targets keyboards and audio. + +## Sleep, debounce, and what this mouse does not have + +**Auto-sleep** has its own command, `0x11 0x0a` — `[enabled, minutes]` — and +lands at **offset 19** of the config blob. Confirmed by writing `0x0a 01 09` and +watching that byte go `0 -> 9`. It is applied slowly: at a 400 ms settle the +write appeared to do nothing at all, and 1500 ms was needed before the new value +read back. Zero minutes disables the timer. + +**Debounce** has no dedicated command; it rides in the config blob at **offset +18** and is written with the ordinary `0x57` read-modify-write. Confirmed by +writing 8 ms and then 4 ms — each read back, and byte 18 was the only byte that +moved. The firmware takes 0-20 ms. + +Between them these two pin the tail of the blob: with offset 19 proven to be +sleep, the schema's `dpiSum` / `sensor` / `keyDebounce` / `sleep` run of bytes +16-19 is correctly aligned, even though `sensor` reads `0x80` where the vendor's +defaults suggest `2`. + +**`0x11 0x42` is the performance command** — `lod`, `ripple`, `line`, +`motionSync`, `gameMode`, `rotateOpen`, `rotateVal`. The vendor sends partial +objects (just `lod`, say); anything omitted arrives as 0, and the toggles use +1 = on / 2 = off, so 0 reads as "leave this field alone". Lift-off is the +exception — index 0 is a real level, and the vendor always sends it. + +It has no entry in the read map, but **everything it sets is still readable**: +the config blob's `sensor` byte (offset 17) is a bitfield holding lift-off *and* +the three processing toggles. Mapped bit by bit on hardware by switching each on +in turn: + +| Bit | Mask | Field | +| --- | --- | --- | +| 0-1 | `0x03` | lift-off step | +| 2 | `0x04` | ripple control | +| 3 | `0x08` | linear correction (angle snapping) | +| 4 | `0x10` | motion sync | +| 5 | `0x20` | unexplained; always clear on the test hardware | +| 6-7 | `0xc0` | **performance mode**, three-way — see below | + +``` +0x80 -> 0x90 motion sync on +0x90 -> 0x94 ripple on +0x94 -> 0x9c linear correction on +``` + +> **Lift-off is two bits, not three.** An early version of this driver masked +> `0x07`, which reads ripple control as part of the level — a mouse with ripple +> on and lift-off 0 reports level 4. Bits 5-6 are unexplained and bit 7 is +> always set, so a writer must preserve the rest of the byte rather than assign +> it. + +### Performance mode — field 7, and bits 6-7 of `sensor` + +M HUB presents this as **three radio buttons**, not a switch. Field 7 takes the +mode number and the result lands in the top two bits of `sensor`: + +| Field 7 | `sensor` bits 6-7 | M HUB label | +| --- | --- | --- | +| 1 | `00` | Performance | +| 2 | `10` | eSports | +| 3 | `11` | Ultra | + +Note the gap — the stored pattern equals the mode number except for +Performance, which stores `0`. Field 7 = `0` leaves the mode alone. + +> **This was first read as a boolean "game mode" on bit 7 alone**, because the +> vendor bundle's other product line uses a checkbox there with `checked ? 2 : 1`. +> That reading is wrong for the A7 V2: bit 6 is the other half of the field, and +> what looked like "off" is really the Performance mode. A screenshot of M HUB's +> own Rendimiento tab is what exposed it. + +**Angle tuning** is field 9 (`rotateVal`), gated by field 8 (`rotateOpen`): +send `rotateOpen = 1` to apply a value, `0` to leave the angle as it is. It +reads back from the config blob at **offset 49**. + +M HUB offers **−30° to +30°** and the byte is a plain two's-complement signed +value (−15 stores `0xf1`). The firmware does **not** validate it — it stored +`0xf1` and `0x8f` unchanged — so the range has to be enforced by the driver. +M HUB also flags this control with "update the mouse firmware", so older +firmware may ignore it. + +This command is the **slowest to apply** of any on the device. At a 1200 ms +settle a read still returned the *previous* state — which looks exactly like the +write being rejected. Allow ~2 s and re-send until the value follows; the driver +retries three times. Verified on an A7 V2 Ultra+ that each toggle round-trips +independently, that changing a toggle leaves lift-off alone (and vice versa), +and that game mode and angle tuning both round-trip and restore. + +Step counts are per model: the Ultra and Ultra+ offer 0.7 mm, 1 mm and 2 mm +(Low/Medium/High); the Pro and Pro+ only 1 mm and 2 mm (Low/High). + +**The A7 V2 Ultra+ has no controllable lighting.** The lighting read +(`0x11 0x1b`, and the `0x11 0x2b` / `0x12 0x2d` write schemas alongside it) is +present in the protocol for other MCHOSE models, but on this mouse the whole +15-byte block reads back as zeros — enable, brightness, effect, both colours. +Do not add lighting controls for it. + +## DPI stage count and profile names + +**Stage count** is `dpiSum` at config offset 16 and is written through the +ordinary `0x57` read-modify-write. Confirmed: writing 4 read back as 4, only +that byte moved, and a performance write in between did not disturb it. The +test hardware reported `1` as its stored value even with six stage slots +populated, so treat it as "how many the mouse cycles", not "how many are filled". + +**Profile names** come from `0x12 0x68 ` — the reply is the profile index +followed by NUL-terminated ASCII. The test hardware answered `Config 1`, +`Config 2`, `Config 3`. This is the same `0x68` listed with no parser in M HUB's +read table, which is why it looked unused. + +## What this mouse cannot do + +- **The mouse itself has no controllable lighting.** Its lighting block + (`0x11 0x1b`, with the `0x11 0x2b` / `0x12 0x2d` write schemas) reads back all + zeros on an A7 V2 Ultra+. The RGB in this product family is on the **charging + base**, which is a separate device — see below. Do not conclude "no lighting" + from the mouse's own channel, as was done here at first. +- **Independent Y-axis DPI is not writable.** The config blob carries a second + stage table (`dpiVal0-5`, offsets 51-62) that mirrors the X values, but + writing a different Y value through `0x57` is silently ignored — the payload + comes back unchanged. Whatever gates it is not the `val` byte at offset 50, + which also refuses writes. Treat Y DPI as read-only and equal to X. +- **Macros** (`0x12 0x65`, and button type 4) are readable enough to preserve — + a button already carrying a macro decodes as "Macro" rather than being + clobbered — but recording one is not implemented. + +## The MagDock — a second device, a second protocol + +The magnetic charging base (`MCHOSE MagDock`, product id **`0x1012`**, usage +page **`0xff00`**) is where this family's RGB actually lives. It is a WCH +controller, not the mouse's RealTek, and shares nothing with the mouse protocol: +frames are **not inverted**, and they go on **unnumbered output report 0**. + +``` +[0] 0xaa start [1] cmd [2] cmdType (0 request, 2 response) +[3] frameSeq [4] totalFrame [5] paramLen [6…] params +``` + +Replies use the same layout, so a reply's payload begins at offset 6. + +| Command | Purpose | +| --- | --- | +| `7` | read the lighting block | +| `39` | write the lighting block | +| `42`, `43` | unidentified | +| `116`, `117` | firmware update (CRC8 poly 0x8c, 56-byte chunks) | + +### Lighting block + +Read payload, and the ten parameters command `39` takes back in the same order: + +| Offset | Field | +| --- | --- | +| 0 | on/off | +| 1 | effect | +| 2 | effect count (echo it back) | +| 3 | speed, 0-4 | +| 4 | brightness, 0-4 | +| 5 | music sync | +| 6-8 | base colour R, G, B | +| 9 | direction | +| 29 | direction, as reported | + +Effects, from MCHOSE's own enum: `0` static, `1` breathing, `2` shining, +`3` colour cycling, `4` flow, `5` music. + +**There is no partial write** — command `39` takes the whole block, so a caller +changing one field must send the rest as they were, including `effectCount`. + +Captured from a real MagDock, on colour cycling at brightness 2 with a red base +colour: + +``` +aa 07 02 00 00 1e 01 03 06 02 02 00 ff 00 00 00 00 … + ^on ^cycling ^count ^speed ^bright ^sync ^rgb(255,0,0) +``` + +Verified by echoing the block back (a no-op), then changing brightness and +setting a static green, then restoring — every step read back and the block +returned byte-identical. + +## Both links, confirmed + +The driver was exercised on an A7 V2 Ultra+ over **both** connections. + +| | 2.4 GHz receiver | USB cable | +| --- | --- | --- | +| Host-facing product id | `0x100b` (shared) | `0x4021` (model-specific) | +| Live half of the byte pair | wireless (offset 2) | wired (offset 1) | +| `chargeStatus` | `0` | `1` while charging | + +Which half is live follows from the product id: over a cable the host talks to +the mouse itself, so the id is a model id; every other link enumerates under a +shared receiver id. Confirmed by writing a polling change over the cable and +watching **only** the wired byte move. + +With the mouse on its cable the receiver stays enumerated but has nothing behind +it, so its config read simply fails — a driver must degrade rather than treat +that as an error. diff --git a/package.json b/package.json index 4ba09eb..ea8ce3b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,10 @@ "types": "./dist/lamzu/index.d.ts", "import": "./dist/lamzu/index.js" }, + "./mchose": { + "types": "./dist/mchose/index.d.ts", + "import": "./dist/mchose/index.js" + }, "./logitech": { "types": "./dist/logitech/index.d.ts", "import": "./dist/logitech/index.js" diff --git a/src/drivers/mchose/dock-hid.test.ts b/src/drivers/mchose/dock-hid.test.ts new file mode 100644 index 0000000..b639216 --- /dev/null +++ b/src/drivers/mchose/dock-hid.test.ts @@ -0,0 +1,127 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { MchoseDockHidClient } from "./dock-hid.ts"; +import { MchoseHidClient } from "./hid.ts"; +import { MCHOSE_DOCK_COMMAND, MCHOSE_DOCK_EFFECT } from "@openmouse/protocol/mchose"; + +/** The lighting block a real MagDock returned: cycling, brightness 2, red. */ +const LIGHTING = [0x01, 0x03, 0x06, 0x02, 0x02, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00]; + +function fakeDock(overrides?: Partial) { + const state = { params: [...LIGHTING] }; + const listeners: Array<(event: unknown) => void> = []; + + const reply = (command: number): Uint8Array => { + const buf = new Uint8Array(63); + buf.set([0xaa, command, 0x02, 0x00, 0x00, 0x1e], 0); + buf.set(state.params, 6); + return buf; + }; + + const device = { + vendorId: 0x3837, + productId: 0x1012, + productName: "MCHOSE MagDock", + opened: true, + collections: [ + { usagePage: 0xff00, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + open: async () => {}, + close: async () => {}, + addEventListener: (_type: string, fn: (event: unknown) => void) => { listeners.push(fn); }, + removeEventListener: (_type: string, fn: (event: unknown) => void) => { + const at = listeners.indexOf(fn); + if (at >= 0) listeners.splice(at, 1); + }, + sendReport: async (_id: number, data: ArrayBuffer | ArrayLike) => { + const frame = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); + const command = frame[1]!; + if (command === MCHOSE_DOCK_COMMAND.writeLighting) { + // The dock takes the whole block at once. + state.params = [...frame.subarray(6, 6 + 10), state.params[10]!]; + } + // Answer on the next tick, as the real device does. + const answer = reply(command === MCHOSE_DOCK_COMMAND.writeLighting + ? MCHOSE_DOCK_COMMAND.writeLighting + : command); + setTimeout(() => { + for (const fn of [...listeners]) fn({ data: new DataView(answer.buffer), reportId: 0 }); + }, 0); + }, + ...overrides, + } as unknown as HIDDevice; + + return { device, state }; +} + +describe("MchoseDockHidClient", () => { + it("claims the MagDock on its own usage page", () => { + assert.equal(MchoseDockHidClient.isSupported(fakeDock().device), true); + }); + + it("does not claim the mouse, and the mouse driver does not claim it", () => { + const dock = fakeDock().device; + assert.equal(MchoseHidClient.isSupported(dock), false, "mouse driver rejects the dock"); + + const mouse = fakeDock({ + productId: 0x100b, + collections: [ + { usagePage: 0xff01, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + } as unknown as Partial).device; + assert.equal(MchoseDockHidClient.isSupported(mouse), false, "dock driver rejects the mouse"); + }); + + it("rejects another vendor's device on the same usage page", () => { + const { device } = fakeDock({ vendorId: 0x046d } as Partial); + assert.equal(MchoseDockHidClient.isSupported(device), false); + }); + + it("reads the base lighting and shapes it as a non-mouse status", async () => { + const status = await new MchoseDockHidClient(fakeDock().device).readStatus(); + assert.equal(status.brand, "MCHOSE"); + assert.equal(status.name, "MCHOSE MagDock"); + assert.equal(status.ui?.settingsReady, false, "not a mouse: no settings grid"); + assert.equal(status.lighting?.zone, "Base"); + assert.equal(status.lighting?.mode, "Cycling"); + assert.equal(status.lighting?.color, "#ff0000"); + assert.equal(status.lighting?.brightness, 2); + assert.ok(status.lighting?.modes.includes("Off")); + }); + + it("setLighting writes the whole block, carrying over what is unchanged", async () => { + const { device, state } = fakeDock(); + const client = new MchoseDockHidClient(device); + const status = await client.readStatus(); + await client.setLighting({ ...status.lighting!, mode: "Static", color: "#00ff00" }); + + assert.equal(state.params[0], 1, "still on"); + assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.static); + assert.deepEqual(state.params.slice(6, 9), [0x00, 0xff, 0x00], "green"); + assert.equal(state.params[3], 2, "speed carried over"); + assert.equal(state.params[2], 6, "effect count carried over"); + }); + + it("Off switches the base off without losing the effect", async () => { + const { device, state } = fakeDock(); + const client = new MchoseDockHidClient(device); + const status = await client.readStatus(); + await client.setLighting({ ...status.lighting!, mode: "Off" }); + assert.equal(state.params[0], 0, "disabled"); + assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.cycling, "effect remembered"); + assert.equal((await client.readStatus()).lighting?.mode, "Off"); + }); + + it("selecting Reactive turns on the music sync flag", async () => { + const { device, state } = fakeDock(); + const client = new MchoseDockHidClient(device); + const status = await client.readStatus(); + await client.setLighting({ ...status.lighting!, mode: "Reactive" }); + assert.equal(state.params[1], MCHOSE_DOCK_EFFECT.music); + assert.equal(state.params[5], 1, "music sync set"); + }); + + it("getDpiOptions is callable, as the app calls it for every client", () => { + assert.deepEqual(new MchoseDockHidClient(fakeDock().device).getDpiOptions(), []); + }); +}); diff --git a/src/drivers/mchose/dock-hid.ts b/src/drivers/mchose/dock-hid.ts new file mode 100644 index 0000000..f8e36f6 --- /dev/null +++ b/src/drivers/mchose/dock-hid.ts @@ -0,0 +1,208 @@ +import { + MCHOSE_DOCK_COMMAND, + MCHOSE_DOCK_COLOR_MODES, + MCHOSE_DOCK_LEVELS, + MCHOSE_DOCK_MODE_LABELS, + MCHOSE_DOCK_PRODUCT_ID, + MCHOSE_DOCK_REPORT_ID, + MCHOSE_DOCK_USAGE, + MCHOSE_DOCK_USAGE_PAGE, + mchoseDockColorFromHex, + mchoseDockColorToHex, + mchoseDockDecodeLighting, + mchoseDockEffectFor, + mchoseDockEncode, + mchoseDockEncodeLighting, + mchoseDockModeLabel, + mchoseDockPayload, + type MchoseDockLighting, +} from "@openmouse/protocol/mchose"; +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts"; +import { VENDOR_ID } from "../vendors.ts"; + +/** + * MCHOSE MagDock — the charging base, which is where this family's RGB lives. + * The A7 V2 mice have no controllable LEDs of their own. + * + * It is not a mouse, so it follows the non-mouse pattern: a `MouseStatus` with + * `ui.settingsReady: false` so the settings grid stays hidden, carrying only + * the lighting the device actually has. + */ + +const REPLY_TIMEOUT_MS = 700; +const WRITE_SETTLE_MS = 400; +const READ_ATTEMPTS = 6; + +const delay = (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); + +export class MchoseDockHidClient { + readonly device: HIDDevice; + + private queue: Promise = Promise.resolve(); + private lastKnown: MchoseDockLighting | null = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === MCHOSE_DOCK_USAGE_PAGE + && collection.usage === MCHOSE_DOCK_USAGE) + || collection.children.some(search); + return device.vendorId === VENDOR_ID.mchose + && device.productId === MCHOSE_DOCK_PRODUCT_ID + && device.collections.some(search); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + this.lastKnown = null; + if (this.device.opened) await this.device.close(); + } + + /** The dock pushes no unsolicited state the panel can use. */ + async startNotifications(): Promise { + return false; + } + + displayName(): string { + return this.device.productName?.trim() || "MCHOSE MagDock"; + } + + /** Called for every connected client, dock or not. */ + getDpiOptions(): number[] { + return []; + } + + /** Send a request and wait for the matching reply on the input report. */ + private request(command: number, params: readonly number[] = []): Promise { + const run = async (): Promise => { + const frame = mchoseDockEncode(command, params); + for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) { + const reply = await new Promise((resolve) => { + const timer = setTimeout(() => { + this.device.removeEventListener("inputreport", listener); + resolve(null); + }, REPLY_TIMEOUT_MS); + const listener = (event: Event): void => { + const report = event as HIDInputReportEvent; + const payload = mchoseDockPayload(new Uint8Array(report.data.buffer), command); + if (!payload) return; + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + resolve(payload); + }; + this.device.addEventListener("inputreport", listener); + this.device.sendReport(MCHOSE_DOCK_REPORT_ID, frame).catch(() => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + resolve(null); + }); + }); + if (reply) return reply; + } + return null; + }; + const next = this.queue.then(run, run); + this.queue = next.catch(() => undefined); + return next; + } + + private async readLighting(): Promise { + await this.open(); + const payload = await this.request(MCHOSE_DOCK_COMMAND.readLighting); + const decoded = payload ? mchoseDockDecodeLighting(payload) : null; + if (decoded) this.lastKnown = decoded; + return decoded; + } + + async readStatus(): Promise { + const lighting = await this.readLighting(); + + return { + brand: "MCHOSE", + name: this.displayName(), + batteryPercent: null, + batteryState: "Unknown", + dpi: 0, + pollingRateHz: 0, + activeProfile: null, + liftOffDistance: null, + connectionType: "Wired", + firmware: [], + lighting: lighting ? this.toMouseLighting(lighting) : undefined, + ui: { + family: "mchose-dock", + // Not a mouse: the settings grid would render nothing but blanks. + settingsReady: false, + defaultDisplayName: "MCHOSE MagDock", + statusNote: lighting + ? "Charging base — lighting only. The A7 V2 mice have no LEDs of their own." + : "Charging base — the lighting state could not be read.", + }, + }; + } + + private toMouseLighting(state: MchoseDockLighting): MouseLighting { + const modes = MCHOSE_DOCK_MODE_LABELS.map(([, label]) => label as MouseLightingMode); + return { + zone: "Base", + modes: ["Off", ...modes], + mode: state.enabled ? (mchoseDockModeLabel(state.effect) as MouseLightingMode) : "Off", + color: mchoseDockColorToHex(state.color), + color2: null, + colorModes: MCHOSE_DOCK_COLOR_MODES as MouseLightingMode[], + dualColorModes: [], + reactiveModes: [], + speeds: [...MCHOSE_DOCK_LEVELS], + speed: state.speed, + brightness: state.brightness, + brightnessLevels: [...MCHOSE_DOCK_LEVELS], + }; + } + + /** + * The dock takes its whole lighting block in one write, so anything the panel + * does not specify is carried over from the last read rather than zeroed. + */ + async setLighting(next: MouseLighting): Promise { + const current = this.lastKnown ?? await this.readLighting(); + if (!current) throw new Error("The dock did not report its lighting state."); + + const off = next.mode === "Off"; + const effect = off ? current.effect : mchoseDockEffectFor(next.mode ?? "") ?? current.effect; + const color = next.color ? mchoseDockColorFromHex(next.color) : null; + + const state: MchoseDockLighting = { + enabled: !off, + effect, + effectCount: current.effectCount, + speed: next.speed ?? current.speed, + brightness: next.brightness ?? current.brightness, + // The music effect is what the sync flag is for. + musicSync: effect === mchoseDockEffectFor("Reactive"), + color: color ?? current.color, + direction: current.direction, + }; + + await this.open(); + const frame = mchoseDockEncodeLighting(state); + const send = async (): Promise => { + await this.device.sendReport(MCHOSE_DOCK_REPORT_ID, frame); + await delay(WRITE_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readLighting(); + if (!after) throw new Error("The dock did not confirm the lighting change."); + if (after.enabled !== state.enabled || (state.enabled && after.effect !== state.effect)) { + throw new Error("The dock did not accept the lighting change."); + } + } +} diff --git a/src/drivers/mchose/hid.test.ts b/src/drivers/mchose/hid.test.ts new file mode 100644 index 0000000..de5f943 --- /dev/null +++ b/src/drivers/mchose/hid.test.ts @@ -0,0 +1,609 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { MchoseHidClient } from "./hid.ts"; +import { + MCHOSE_COMMAND, + MCHOSE_LONG_REPORT_ID, + MCHOSE_SHORT_REPORT_ID, +} from "@openmouse/protocol/mchose"; + +/** + * Replies below are the bytes a real A7 V2 Ultra+ returned through its 2.4 GHz + * receiver while set to 1000 Hz and 1600 DPI (mchose-research captures). They + * are re-inverted by the fake device, because that is how they arrive. + */ +const IDENTITY = [0x01, 0x37, 0x38, 0x0b, 0x10, 0x01, 0x00]; +const VERSION = [0x08, 0x35, 0x2e, 0x34, 0x36, 0x2e, 0x32, 0x2e, 0x34]; +const BATTERY = [0x37, 0x38, 0x21, 0x40, 0x05, 0x2e, 0x02, 0x04, 0x09, 0x29, 0x00, 0x2c]; +const CONFIG = [ + 0x00, 0x30, 0x20, 0x00, 0x40, 0x06, 0x20, 0x03, 0x40, 0x06, + 0x80, 0x0c, 0x00, 0x19, 0x10, 0xa4, 0x01, 0x80, 0x00, 0x00, + // Button table, as captured: forward and back sit on F9 and F10. + 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, + 0x32, 0x00, 0x42, 0x00, + 0x42, 0x00, 0x43, 0x00, + 0x50, 0x00, 0x00, 0x00, +]; + +/** Build the on-the-wire feature read: [reportId, ~command, ~payload…]. */ +function wireReply(reportId: number, command: number, payload: number[]): DataView { + const buf = new Uint8Array(66); + buf[0] = reportId; + buf[1] = (~command) & 0xff; + payload.forEach((byte, index) => { buf[index + 2] = (byte ^ 0xff) & 0xff; }); + return new DataView(buf.buffer); +} + +/** + * A fake that behaves like the real device: one shared reply buffer per report + * id, and a config that a 0x57 write actually mutates, so a setter's read-back + * check is exercised rather than stubbed. + */ +/** Profile 2 as captured from the same mouse: 1000 Hz on stage 2. */ +const PROFILE_2 = [ + 0x02, 0x32, 0x22, 0x00, 0x40, 0x06, 0x20, 0x03, 0x40, 0x06, + 0x80, 0x0c, 0x00, 0x19, 0x10, 0xa4, 0x01, 0x80, 0x00, 0x00, + ...CONFIG.slice(20), +]; + +function fakeDevice(overrides?: Partial) { + const state = { config: [...CONFIG], writes: [] as number[][], profile: 0, sleepEnabled: 0, mouseProductId: 0x4021 }; + let pendingShort = MCHOSE_COMMAND.identity; + let pendingLong = MCHOSE_COMMAND.config; + + const device = { + vendorId: 0x3837, + productId: 0x100b, + productName: "MCHOSE A7 V2 Ultra+", + opened: true, + collections: [ + { usagePage: 0xff01, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + open: async () => {}, + close: async () => {}, + sendFeatureReport: async (id: number, data: ArrayBuffer | ArrayLike) => { + const raw = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); + const tokens = [...raw].map((b) => (b ^ 0xff) & 0xff); + const command = tokens[0]!; + if (id === MCHOSE_SHORT_REPORT_ID) { + if (command === MCHOSE_COMMAND.setPerformance) { + // [command, lod, ripple, line, motionSync]: lift-off replaces bits + // 0-1, and each toggle is 1 = on, 2 = off, 0 = leave alone. + let sensor = (state.config[17]! & ~0x03) | (tokens[1]! & 0x03); + const apply = (raw: number, bit: number): void => { + if (raw === 1) sensor |= bit; + if (raw === 2) sensor &= ~bit; + }; + apply(tokens[2]!, 0x04); // ripple + apply(tokens[3]!, 0x08); // linear correction + apply(tokens[4]!, 0x10); // motion sync + // Mode is a two-bit field: 1 stores 00, 2 stores 10, 3 stores 11. + if (tokens[7]! >= 1 && tokens[7]! <= 3) { + sensor = (sensor & ~0xc0) | ((tokens[7] === 1 ? 0 : tokens[7]!) << 6); + } + state.config[17] = sensor & 0xff; + // rotateOpen gates whether the angle value is applied. + if (tokens[8] === 1) state.config[49] = tokens[9]!; + } + if (command === MCHOSE_COMMAND.setSleep) { + // Sleep has its own command but lands in the config blob at 19. + state.sleepEnabled = tokens[1]!; + state.config[19] = tokens[2]!; + } + if (command === MCHOSE_COMMAND.setProfile) { + // Each profile holds its own settings, so the config changes with it. + state.profile = tokens[1]!; + state.config = state.profile === 0 ? [...CONFIG] : [...PROFILE_2]; + state.config[0] = state.profile; + } + pendingShort = command; + return; + } + if (command === MCHOSE_COMMAND.writeConfig) { + // The write layout is the read layout with the command byte in front. + state.writes.push(tokens); + state.config = tokens.slice(1, 1 + CONFIG.length); + pendingLong = MCHOSE_COMMAND.writeConfig; + return; + } + if (command === MCHOSE_COMMAND.setButton) { + // A standalone command: it touches one button's four bytes and nothing + // else, which is what the hardware was observed to do. + const at = 20 + tokens[1]! * 4; + state.config[at] = ((tokens[1]! & 0x0f) << 4) | (tokens[3]! & 0x0f); + state.config[at + 1] = tokens[4]!; + state.config[at + 2] = tokens[5]!; + state.config[at + 3] = tokens[6]!; + pendingLong = MCHOSE_COMMAND.setButton; + return; + } + pendingLong = command; + }, + receiveFeatureReport: async (id: number) => { + if (id === MCHOSE_SHORT_REPORT_ID) { + const payload = pendingShort === MCHOSE_COMMAND.identity ? IDENTITY + : pendingShort === MCHOSE_COMMAND.version ? VERSION + : pendingShort === MCHOSE_COMMAND.battery + ? [...BATTERY.slice(0, 2), state.mouseProductId & 0xff, (state.mouseProductId >> 8) & 0xff, ...BATTERY.slice(4)] + : []; + return wireReply(id, pendingShort, payload); + } + // A write leaves no readable answer; the driver must re-issue 0x67. + if (pendingLong === MCHOSE_COMMAND.writeConfig || pendingLong === MCHOSE_COMMAND.setButton) { + return wireReply(id, pendingLong, []); + } + return wireReply(id, MCHOSE_COMMAND.config, state.config); + }, + ...overrides, + } as unknown as HIDDevice; + + return { device, state }; +} + +describe("MchoseHidClient", () => { + it("matches an MCHOSE device exposing the 0xff01 config collection", () => { + assert.equal(MchoseHidClient.isSupported(fakeDevice().device), true); + }); + + it("rejects another vendor on the same usage page", () => { + const { device } = fakeDevice({ vendorId: 0x046d } as Partial); + assert.equal(MchoseHidClient.isSupported(device), false); + }); + + it("rejects an MCHOSE device with only the firmware-update collection", () => { + const { device } = fakeDevice({ + collections: [ + { usagePage: 0xff0b, usage: 0x0104, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + } as unknown as Partial); + assert.equal(MchoseHidClient.isSupported(device), false); + }); + + it("finds the config collection nested under a parent", () => { + const { device } = fakeDevice({ + collections: [{ + usagePage: 0x0001, usage: 0x0002, type: 0, input: 0, output: 0, feature: 0, + children: [ + { usagePage: 0xff01, usage: 0x0001, type: 0, children: [], input: 0, output: 0, feature: 0 }, + ], + }], + } as unknown as Partial); + assert.equal(MchoseHidClient.isSupported(device), true); + }); + + it("sends commands bit-inverted, padded to the report length", async () => { + const sent: Array<{ id: number; bytes: Uint8Array }> = []; + const base = fakeDevice(); + const device = { + ...base.device, + sendFeatureReport: async (id: number, data: ArrayBuffer | ArrayLike) => { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer); + sent.push({ id, bytes }); + await base.device.sendFeatureReport(id, data as ArrayBuffer); + }, + } as unknown as HIDDevice; + await new MchoseHidClient(device).readStatus(); + assert.equal(sent[0]!.id, MCHOSE_SHORT_REPORT_ID); + assert.equal(sent[0]!.bytes[0], (~MCHOSE_COMMAND.identity) & 0xff); + assert.equal(sent[0]!.bytes.length, 64); + }); + + it("reads a full status matching the captured device state", async () => { + const status = await new MchoseHidClient(fakeDevice().device).readStatus(); + assert.equal(status.brand, "MCHOSE"); + assert.equal(status.name, "MCHOSE A7 V2 Ultra+", "identified by the mouse id in the battery reply"); + assert.equal(status.batteryPercent, 41); + assert.equal(status.batteryState, "Discharging"); + assert.deepEqual(status.dpiStages, [1600, 800, 1600, 3200, 6400, 42000]); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.dpi, 1600, "the wireless link is on stage 0"); + assert.equal(status.pollingRateHz, 1000, "wireless byte 0x20 -> rate index 2"); + assert.equal(status.connectionType, "Wireless"); + assert.deepEqual(status.firmware, ["Receiver 5.46.2.4"]); + }); + + it("offers the 8K rate list and an editable stage list", async () => { + const status = await new MchoseHidClient(fakeDevice().device).readStatus(); + assert.deepEqual(status.supportedPollingRates, [125, 500, 1000, 2000, 4000, 8000]); + assert.equal(status.ui?.settingsReady, true); + assert.equal(status.ui?.dpiStageEditor?.maxStages, 6); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 42000); + }); + + it("setPollingRate rewrites only the link byte and preserves the rest", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setPollingRate(500); + + assert.equal(state.writes.length, 1); + const written = state.writes[0]!; + assert.equal(written[0], MCHOSE_COMMAND.writeConfig); + assert.equal(written[1 + 2], 0x10, "rate index 1, stage 0 kept"); + assert.equal(written[1 + 1], CONFIG[1], "the wired byte is untouched"); + for (let index = 3; index < CONFIG.length; index += 1) { + assert.equal(written[1 + index], CONFIG[index], `byte ${index} echoed`); + } + assert.equal((await client.readStatus()).pollingRateHz, 500); + }); + + it("setPollingRate refuses a rate this link cannot do", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await client.readStatus(); + await assert.rejects(() => client.setPollingRate(3000), /does not support/); + }); + + it("setActiveDpiStage moves the stage and reads back", async () => { + const { device } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setActiveDpiStage(3); + const status = await client.readStatus(); + assert.equal(status.activeDpiStage, 3); + assert.equal(status.dpi, 3200); + assert.equal(status.pollingRateHz, 1000, "changing the stage left the rate alone"); + }); + + it("setActiveDpiStage rejects an out-of-range stage", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await assert.rejects(() => client.setActiveDpiStage(6), /0-5/); + }); + + it("setDpiStageValue rewrites one stage only", async () => { + const { device } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setDpiStageValue(1, 12000); + const status = await client.readStatus(); + assert.deepEqual(status.dpiStages, [1600, 12000, 1600, 3200, 6400, 42000]); + }); + + it("setDpiStageValue refuses a DPI above the model's ceiling", async () => { + const { device } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await assert.rejects(() => client.setDpiStageValue(0, 60000), /between 50 and 42000/); + }); + + it("setDpi changes the stage the live link is using", async () => { + const { device } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setDpi(3200); + const status = await client.readStatus(); + assert.equal(status.dpiStages?.[0], 3200, "stage 0 is the active wireless stage"); + assert.equal(status.dpi, 3200); + }); + + it("reports profiles one-based, as the shell displays them", async () => { + const status = await new MchoseHidClient(fakeDevice().device).readStatus(); + assert.equal(status.activeProfile, 1, "wire index 0 is shown as profile 1"); + assert.equal(status.profileCount, 3); + }); + + it("setProfile switches and confirms", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setProfile(3); + assert.equal(state.profile, 2, "one-based 3 is written as wire index 2"); + const status = await client.readStatus(); + assert.equal(status.activeProfile, 3); + }); + + it("a profile brings its own DPI and polling with it", async () => { + const { device } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setProfile(3); + const status = await client.readStatus(); + // Profile 2 was captured at 1000 Hz on stage 2. + assert.equal(status.pollingRateHz, 1000); + assert.equal(status.activeDpiStage, 2); + }); + + it("setProfile rejects an index outside the mouse's range", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await assert.rejects(() => client.setProfile(0), /1-3/); + await assert.rejects(() => client.setProfile(4), /1-3/); + }); + + it("reports sleep in seconds, converting from the firmware's minutes", async () => { + const { device, state } = fakeDevice(); + state.config[19] = 3; + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.sleepTimeout, 180); + assert.equal(status.debounceMs, 0); + }); + + it("setSleepTimeout rounds seconds to whole minutes", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.setSleepTimeout(300); + assert.equal(state.config[19], 5, "300 s stored as 5 minutes"); + assert.equal(state.sleepEnabled, 1); + }); + + it("setSleepTimeout(0) turns the timer off", async () => { + const { device, state } = fakeDevice(); + await new MchoseHidClient(device).setSleepTimeout(0); + assert.equal(state.config[19], 0); + assert.equal(state.sleepEnabled, 0); + }); + + it("setDebounceTime writes through the config blob", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.setDebounceTime(8); + assert.equal(state.config[18], 8); + // Everything else must survive the read-modify-write. + assert.equal(state.config[1], CONFIG[1]); + assert.equal(state.config[19], CONFIG[19]); + assert.equal((await client.readStatus()).debounceMs, 8); + }); + + it("setDebounceTime refuses a value the firmware will not take", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await assert.rejects(() => client.setDebounceTime(25), /0-20 ms/); + await assert.rejects(() => client.setDebounceTime(-1), /0-20 ms/); + }); + + it("publishes its own sleep options and debounce ceiling", () => { + const client = new MchoseHidClient(fakeDevice().device); + assert.deepEqual(client.getSleepOptions(), [0, 60, 120, 180, 300, 600, 1800]); + assert.equal(client.getDebounceMaxMs(), 20); + }); + + it("reports lift-off and the steps this model offers", async () => { + const status = await new MchoseHidClient(fakeDevice().device).readStatus(); + assert.equal(status.liftOffDistance, "Low", "sensor 0x80 -> step 0"); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "Medium", "High"]); + }); + + it("setLiftOffDistance writes the step and preserves the sensor's upper bits", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setLiftOffDistance("High"); + assert.equal(state.config[17], 0x82, "0x80 flag kept, step 2 in the low bits"); + assert.equal((await client.readStatus()).liftOffDistance, "High"); + }); + + it("setLiftOffDistance does not disturb DPI or polling", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setLiftOffDistance("Medium"); + assert.equal(state.config[1], CONFIG[1], "wired link byte untouched"); + assert.equal(state.config[2], CONFIG[2], "wireless link byte untouched"); + assert.equal(state.config[4], CONFIG[4], "stage 0 untouched"); + }); + + it("a two-step model rejects the middle lift-off step", async () => { + // An A7 V2 Pro (mouse id 0x4018) offers only 1 mm and 2 mm. + const pro = fakeDevice(); + pro.state.mouseProductId = 0x4018; + const client = new MchoseHidClient(pro.device); + const status = await client.readStatus(); + assert.deepEqual(status.supportedLiftOffDistances, ["Low", "High"]); + await assert.rejects(() => client.setLiftOffDistance("Medium"), /does not offer/); + await assert.doesNotReject(() => client.setLiftOffDistance("High")); + }); + + it("reports the button map and the actions it accepts", async () => { + const status = await new MchoseHidClient(fakeDevice().device).readStatus(); + assert.deepEqual(Object.keys(status.buttonMappings ?? {}), [ + "Left", "Middle", "Right", "Forward", "Back", "DPI", + ]); + assert.equal(status.buttonMappings?.Forward, "F9", "matches the captured device"); + assert.equal(status.buttonMappings?.Left, "Default"); + assert.ok((status.buttonOptions?.length ?? 0) > 20); + assert.ok(status.buttonOptions?.includes("Default")); + }); + + it("setButtonMapping writes only that button's four bytes", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + const before = [...state.config]; + await client.setButtonMapping("Forward", "Play / Pause"); + + const at = 20 + 3 * 4; + assert.equal(state.config[at + 1], 0xcd, "media play/pause, big-endian"); + for (let i = 0; i < before.length; i += 1) { + if (i >= at && i < at + 4) continue; + assert.equal(state.config[i], before[i], `byte ${i} untouched`); + } + assert.equal((await client.readStatus()).buttonMappings?.Forward, "Play / Pause"); + }); + + it("setButtonMapping can restore a button to its factory function", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setButtonMapping("Back", "Default"); + const at = 20 + 4 * 4; + assert.equal(state.config[at] & 0x0f, 0, "type 0"); + assert.equal(state.config[at + 1]! | state.config[at + 2]! | state.config[at + 3]!, 0); + assert.equal((await client.readStatus()).buttonMappings?.Back, "Default"); + }); + + it("setButtonMapping rejects an unknown button or action", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await assert.rejects(() => client.setButtonMapping("Thumb", "Default"), /no "Thumb" button/); + await assert.rejects(() => client.setButtonMapping("Left", "Launch rocket"), /Unknown button action/); + }); + + it("reports the processing toggles out of the sensor byte", async () => { + const { device, state } = fakeDevice(); + state.config[17] = 0x9c; // every toggle on, lift-off 0 + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.motionSync, true); + assert.equal(status.rippleControl, true); + assert.equal(status.angleSnapping, true); + assert.equal(status.liftOffDistance, "Low", "the toggles are not part of the level"); + }); + + it("each processing toggle round-trips independently", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + + await client.setMotionSync(true); + assert.equal(state.config[17], 0x90); + await client.setRippleControl(true); + assert.equal(state.config[17], 0x94); + await client.setAngleSnapping(true); + assert.equal(state.config[17], 0x9c); + await client.setMotionSync(false); + assert.equal(state.config[17], 0x8c, "only motion sync cleared"); + }); + + it("changing a toggle leaves lift-off alone, and vice versa", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + + await client.setLiftOffDistance("High"); + await client.setMotionSync(true); + assert.equal((await client.readStatus()).liftOffDistance, "High", "toggle did not disturb lift-off"); + + await client.setLiftOffDistance("Low"); + assert.equal(state.config[17] & 0x10, 0x10, "lift-off change did not clear motion sync"); + assert.equal((await client.readStatus()).motionSync, true); + }); + + it("reports the mode, angle tuning and stage count", async () => { + const { device, state } = fakeDevice(); + state.config[49] = 5; + state.config[16] = 4; + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.powerMode, "eSports", "sensor 0x80 -> eSports"); + assert.deepEqual(status.powerModes, ["Performance", "eSports", "Ultra"]); + assert.equal(status.angleTuning, 5); + assert.equal(status.ui?.dpiStageEditor?.countEditable, true); + }); + + it("setPowerMode moves through all three modes", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + + await client.setPowerMode("Performance"); + assert.equal(state.config[17] & 0xc0, 0x00); + assert.equal((await client.readStatus()).powerMode, "Performance"); + + await client.setPowerMode("Ultra"); + assert.equal(state.config[17] & 0xc0, 0xc0); + assert.equal((await client.readStatus()).powerMode, "Ultra"); + + await client.setPowerMode("eSports"); + assert.equal(state.config[17] & 0xc0, 0x80); + }); + + it("setPowerMode rejects a mode this mouse does not have", async () => { + const client = new MchoseHidClient(fakeDevice().device); + await assert.rejects(() => client.setPowerMode("Turbo"), /no "Turbo" mode/); + }); + + it("changing the mode does not disturb lift-off or the toggles", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setMotionSync(true); + await client.setLiftOffDistance("High"); + await client.setPowerMode("Ultra"); + assert.equal(state.config[17] & 0x10, 0x10, "motion sync survived"); + assert.equal(state.config[17] & 0x03, 2, "lift-off survived"); + const status = await client.readStatus(); + assert.equal(status.liftOffDistance, "High"); + assert.equal(status.motionSync, true); + }); + + it("setAngleTuning handles negative degrees and validates the range", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + await client.setAngleTuning(7); + assert.equal(state.config[49], 7); + assert.equal((await client.readStatus()).angleTuning, 7); + + await client.setAngleTuning(-15); + assert.equal(state.config[49], 0xf1, "two's complement on the wire"); + assert.equal((await client.readStatus()).angleTuning, -15); + + await assert.rejects(() => client.setAngleTuning(45), /-30 to 30/); + await assert.rejects(() => client.setAngleTuning(-45), /-30 to 30/); + }); + + it("setDpiStageCount rewrites only the count", async () => { + const { device, state } = fakeDevice(); + const client = new MchoseHidClient(device); + await client.readStatus(); + const before = [...state.config]; + await client.setDpiStageCount(4); + assert.equal(state.config[16], 4); + for (let i = 0; i < before.length; i += 1) { + if (i === 16) continue; + assert.equal(state.config[i], before[i], `byte ${i} untouched`); + } + await assert.rejects(() => client.setDpiStageCount(0), /1-6/); + await assert.rejects(() => client.setDpiStageCount(7), /1-6/); + }); + + /** + * Over a cable the host talks to the mouse itself (0x4021 for an Ultra+), + * not the shared receiver id, and the *wired* half of the byte pair is the + * live one. Confirmed on hardware with the cable plugged in. + */ + it("reads the wired half when connected over a cable", async () => { + const { device, state } = fakeDevice({ productId: 0x4021 } as Partial); + // Give the two links different settings so the wrong one is obvious. + state.config[1] = 0x10; // wired: rate index 1 (500 Hz), stage 0 + state.config[2] = 0x33; // wireless: rate index 3, stage 3 + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.pollingRateHz, 500, "the wired byte, not the wireless one"); + assert.equal(status.activeDpiStage, 0); + }); + + it("a wired write moves the wired byte and leaves the wireless one alone", async () => { + const { device, state } = fakeDevice({ productId: 0x4021 } as Partial); + const client = new MchoseHidClient(device); + await client.readStatus(); + const wirelessBefore = state.config[2]; + + await client.setPollingRate(500); + assert.equal(state.config[1] >> 4, 1, "wired rate index moved"); + assert.equal(state.config[2], wirelessBefore, "wireless byte untouched"); + assert.equal((await client.readStatus()).pollingRateHz, 500); + }); + + it("degrades to identity-only when the mouse never answers", async () => { + const { device } = fakeDevice({ + receiveFeatureReport: async () => new DataView(new ArrayBuffer(66)), + }); + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.batteryPercent, null); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.dpiStages, undefined); + assert.equal(status.ui?.settingsReady, false, "no settings grid without a config read"); + }); + + it("never mistakes another command's reply for a configuration", async () => { + // The shared reply buffer still holds the battery answer. + const { device } = fakeDevice({ + receiveFeatureReport: async (id: number) => + wireReply(id as number, MCHOSE_COMMAND.battery, BATTERY), + }); + const status = await new MchoseHidClient(device).readStatus(); + assert.equal(status.dpiStages, undefined, "a battery payload is not accepted as config"); + assert.equal(status.ui?.settingsReady, false); + }); + + it("getDpiOptions is callable, as the app calls it for every client", () => { + assert.deepEqual(new MchoseHidClient(fakeDevice().device).getDpiOptions(), []); + }); +}); diff --git a/src/drivers/mchose/hid.ts b/src/drivers/mchose/hid.ts new file mode 100644 index 0000000..b6844b7 --- /dev/null +++ b/src/drivers/mchose/hid.ts @@ -0,0 +1,665 @@ +import { + MCHOSE_COMMAND, + MCHOSE_CONFIG_USAGE, + MCHOSE_CONFIG_USAGE_PAGE, + MCHOSE_DPI_STAGES, + MCHOSE_LONG_REPORT_ID, + MCHOSE_LONG_TOKENS, + MCHOSE_PRODUCTS, + MCHOSE_PROFILE_COUNT, + MCHOSE_SLEEP_OPTIONS, + MCHOSE_DEBOUNCE_MAX_MS, + MCHOSE_DOCK_PRODUCT_ID, + MCHOSE_ANGLE_TUNING_MAX, + MCHOSE_SHORT_REPORT_ID, + MCHOSE_SHORT_TOKENS, + mchoseDecodeBattery, + mchoseDecodeConfig, + mchoseDecodeIdentity, + mchoseDecodeReply, + mchoseDecodeVersion, + mchoseEncodeCommand, + mchoseEncodeConfigWrite, + mchoseEncodeSetProfile, + mchoseEncodeSleep, + mchoseEncodePerformance, + type MchoseProcessing, + mchoseLiftOffLabels, + mchoseDecodeButtons, + mchoseDecodeButtonName, + MCHOSE_BUTTON_TYPE, + type MchoseButtonAssignment, + mchoseDecodeProfileName, + mchoseModeNumber, + MCHOSE_MODES, + MCHOSE_ANGLE_TUNING_MIN, + mchoseEncodeButton, + mchoseFindButtonAction, + MCHOSE_BUTTONS, + MCHOSE_BUTTON_ACTIONS, + mchoseFindProduct, + mchosePollingRates, + type MchoseBattery, + type MchoseConfig, +} from "@openmouse/protocol/mchose"; +import type { MouseStatus } from "../mouse-types.ts"; +import { VENDOR_ID } from "../vendors.ts"; + +/** + * MCHOSE A7 V2 family, over the vendor `0xff01` collection. + * + * The command set and its bit-inverted wire format were recovered from + * MCHOSE's M HUB web driver and verified against an A7 V2 Ultra+ on its 2.4 GHz + * receiver, including a polling-rate write that round-tripped and restored. + * docs/mchose-protocol.md has the details, including which of the device's + * other vendor collections are decoys. + */ + +/** A reply can arrive before the firmware has filled it in, so reads are polled. */ +const POLL_DELAY_MS = 90; +const POLL_ATTEMPTS = 20; +/** Settle time after a config write before the value reads back. */ +const WRITE_SETTLE_MS = 400; +/** The mouse needs longer than a config write before it answers for a new profile. */ +const PROFILE_SETTLE_MS = 600; +/** Sleep is applied more slowly still; 400 ms was not enough on hardware. */ +const SLEEP_SETTLE_MS = 1500; +/** + * The performance command is the slowest to apply; at 1200 ms a read still + * returned the previous state, so it is given longer and retried. + */ +const PERFORMANCE_SETTLE_MS = 2000; +const PERFORMANCE_ATTEMPTS = 3; +/** A button write is a standalone command and applies quickly. */ +const BUTTON_SETTLE_MS = 900; +/** Bytes of the 0x67 reply that carry fields; past this is stale scratch. */ +const CONFIG_MEANINGFUL_BYTES = 20; +const BATTERY_MEANINGFUL_BYTES = 11; + +const DPI_MIN = 50; +const DPI_STEP = 50; + +const delay = (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); + +export class MchoseHidClient { + readonly device: HIDDevice; + + private queue: Promise = Promise.resolve(); + private cachedProduct: ReturnType = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === MCHOSE_CONFIG_USAGE_PAGE + && collection.usage === MCHOSE_CONFIG_USAGE) + || collection.children.some(search); + return device.vendorId === VENDOR_ID.mchose + // The MagDock is the same vendor but a different protocol entirely, and + // has its own driver; it must never be claimed as a mouse. + && device.productId !== MCHOSE_DOCK_PRODUCT_ID + && device.collections.some(search); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + /** MCHOSE pushes unsolicited reports, but their layout is not decoded yet. */ + async startNotifications(): Promise { + return false; + } + + displayName(): string { + const name = this.device.productName?.trim(); + if (!name) return "MCHOSE"; + return /^mchose/i.test(name) ? name : `MCHOSE ${name}`; + } + + isWireless(): boolean { + return !this.isWired(); + } + + /** + * Over a cable the host talks to the mouse itself, so the product id is a + * model id; every other link enumerates as a shared receiver id. Which link + * is live decides which half of the config byte pair applies. + */ + private isWired(): boolean { + return MCHOSE_PRODUCTS.some((entry) => entry.productId === this.device.productId); + } + + /** + * Send a command and poll for its answer. + * + * Two guards matter here, because every command shares one reply buffer: the + * command echo must un-invert to what was sent, and the caller's `accept` + * check must pass. Without both, a stale answer left by a previous command + * reads as a valid one — which is exactly how a config read can hand back a + * battery reply. + */ + private request( + reportId: number, + command: number, + tokenCount: number, + accept: (payload: Uint8Array) => boolean, + extraTokens: readonly number[] = [], + ): Promise { + const run = async (): Promise => { + const body = mchoseEncodeCommand([command, ...extraTokens], tokenCount); + let previous: Uint8Array | null = null; + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) { + await this.device.sendFeatureReport(reportId, body); + await delay(POLL_DELAY_MS); + let raw: DataView; + try { + raw = await this.device.receiveFeatureReport(reportId); + } catch { + continue; + } + const reply = mchoseDecodeReply(new Uint8Array(raw.buffer)); + if (!reply || reply.command !== command) continue; + if (!accept(reply.payload)) continue; + if (previous && reply.payload.every((byte, index) => byte === previous![index])) { + return reply.payload; + } + previous = Uint8Array.from(reply.payload); + } + return null; + }; + const next = this.queue.then(run, run); + this.queue = next.catch(() => undefined); + return next; + } + + private nonZero(length: number) { + return (payload: Uint8Array): boolean => + payload.subarray(0, length).some((byte) => byte !== 0); + } + + /** A config payload always carries a plausible first DPI stage. */ + private static looksLikeConfig(payload: Uint8Array): boolean { + if (payload.length < CONFIG_MEANINGFUL_BYTES) return false; + const firstStage = (payload[4] ?? 0) | ((payload[5] ?? 0) << 8); + return firstStage >= DPI_MIN && firstStage <= 42000; + } + + private readConfigPayload(): Promise { + return this.request( + MCHOSE_LONG_REPORT_ID, + MCHOSE_COMMAND.config, + MCHOSE_LONG_TOKENS, + MchoseHidClient.looksLikeConfig, + ); + } + + /** + * Read the config, apply `changes`, write it back and confirm it took. The + * whole payload is echoed apart from the changed fields, so button mappings + * and macros are preserved rather than zeroed by a partial write. + */ + private async updateConfig( + changes: Parameters[1], + ): Promise { + await this.open(); + const before = await this.readConfigPayload(); + if (!before) throw new Error("The mouse did not return its configuration."); + + const tokens = mchoseEncodeConfigWrite(before, changes); + const body = mchoseEncodeCommand(tokens, MCHOSE_LONG_TOKENS); + const send = async (): Promise => { + await this.device.sendFeatureReport(MCHOSE_LONG_REPORT_ID, body); + await delay(WRITE_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readConfigPayload(); + const decoded = after ? mchoseDecodeConfig(after) : null; + if (!decoded) throw new Error("The mouse did not confirm the new configuration."); + return decoded; + } + + private linkRates(product: ReturnType): readonly number[] { + return product ? mchosePollingRates(product, this.device.productId) : []; + } + + async readStatus(): Promise { + await this.open(); + + // Identity and version answer from the receiver itself and are reliable; + // battery and config travel over the RF link, so a partial read degrades + // rather than breaking the connect flow. + const identity = mchoseDecodeIdentity( + (await this.request(MCHOSE_SHORT_REPORT_ID, MCHOSE_COMMAND.identity, MCHOSE_SHORT_TOKENS, this.nonZero(7))) ?? new Uint8Array(), + ); + const version = mchoseDecodeVersion( + (await this.request(MCHOSE_SHORT_REPORT_ID, MCHOSE_COMMAND.version, MCHOSE_SHORT_TOKENS, this.nonZero(2))) ?? new Uint8Array(), + ); + + let battery: MchoseBattery | null = null; + const batteryPayload = await this.request( + MCHOSE_SHORT_REPORT_ID, MCHOSE_COMMAND.battery, MCHOSE_SHORT_TOKENS, + this.nonZero(BATTERY_MEANINGFUL_BYTES), + ); + if (batteryPayload) battery = mchoseDecodeBattery(batteryPayload); + + const configPayload = await this.readConfigPayload(); + const config = configPayload ? mchoseDecodeConfig(configPayload) : null; + + const product = mchoseFindProduct(battery?.productId ?? null, this.device.productName); + this.cachedProduct = product; + const wired = this.isWired(); + const stageIndex = config ? (wired ? config.wiredDpiIndex : config.wirelessDpiIndex) : 0; + const rateIndex = config ? (wired ? config.wiredRateIndex : config.wirelessRateIndex) : -1; + const stages = config?.dpiStages ?? []; + const rates = this.linkRates(product); + const liftOffSteps = product?.liftOffDistances.length ?? 3; + const buttons = configPayload ? mchoseDecodeButtons(configPayload) : null; + if (buttons) await this.labelMacros(buttons); + const profileNames = config ? await this.readProfileNames() : null; + + const firmware: string[] = []; + if (version) firmware.push(`Receiver ${version}`); + + return { + brand: "MCHOSE", + name: product ? `MCHOSE ${product.name}` : this.displayName(), + batteryPercent: battery?.batteryPercent ?? null, + batteryState: battery?.charging ? "Charging" : battery ? "Discharging" : "Unknown", + dpi: stages[stageIndex] ?? 0, + dpiStages: stages.length ? stages : undefined, + activeDpiStage: stages.length ? stageIndex : undefined, + pollingRateHz: rates[rateIndex] ?? 0, + supportedPollingRates: rates.length ? [...rates] : undefined, + // The wire index is 0-based; the shell shows profiles counting from one. + activeProfile: config ? config.profileIndex + 1 : null, + profileCount: config ? MCHOSE_PROFILE_COUNT : undefined, + profileNames: profileNames ?? undefined, + debounceMs: config?.keyDebounce ?? null, + sleepTimeout: config ? config.sleep * 60 : null, + connectionType: wired ? "Wired" : "Wireless", + connectionDetail: identity && !identity.connected + ? "Receiver connected, mouse not linked" + : undefined, + liftOffDistance: config ? (mchoseLiftOffLabels(liftOffSteps)[config.liftOffIndex] ?? null) : null, + motionSync: config ? config.processing.motionSync : null, + angleSnapping: config ? config.processing.angleSnapping : null, + rippleControl: config ? config.processing.rippleControl : null, + powerMode: config ? config.mode : undefined, + powerModes: config ? [...MCHOSE_MODES] : undefined, + angleTuning: config ? config.angleTuning : null, + supportedLiftOffDistances: config ? mchoseLiftOffLabels(liftOffSteps) : undefined, + buttonMappings: buttons + ? Object.fromEntries(MCHOSE_BUTTONS.map((name, index) => [name, buttons[index]!.label])) + : undefined, + buttonOptions: buttons ? this.getButtonOptions() : undefined, + firmware, + ui: { + family: "mchose", + settingsReady: Boolean(config), + valuesVerified: Boolean(config), + defaultDisplayName: "MCHOSE", + forceShowBattery: true, + hideSignalCard: true, + hideUnsupportedPollingRates: true, + // Each link stores its own polling rate and DPI stage; only the one in + // use is shown, so say which that is. + pollingNote: wired + ? "Applies to the wired connection." + : "Applies to the 2.4 GHz connection; Bluetooth is capped at 1000 Hz.", + dpiStageEditor: { + maxStages: MCHOSE_DPI_STAGES, + countEditable: true, + minDpi: DPI_MIN, + maxDpi: product?.dpiMax ?? 26000, + stepDpi: DPI_STEP, + }, + }, + }; + } + + /** Called for every connected client; the stage editor drives DPI here. */ + getDpiOptions(): number[] { + return []; + } + + // ── setters ────────────────────────────────────────────────────────────── + + async setPollingRate(hertz: number): Promise { + const rates = this.linkRates(this.cachedProduct); + const index = rates.indexOf(hertz); + if (index < 0) throw new Error(`This mouse does not support ${hertz} Hz on this connection.`); + const key = this.isWired() ? "wiredRateIndex" : "wirelessRateIndex"; + const after = await this.updateConfig({ [key]: index }); + const applied = this.isWired() ? after.wiredRateIndex : after.wirelessRateIndex; + if (applied !== index) throw new Error("The mouse did not accept the new polling rate."); + } + + async setActiveDpiStage(stage: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= MCHOSE_DPI_STAGES) { + throw new Error(`DPI stage must be 0-${MCHOSE_DPI_STAGES - 1}.`); + } + const key = this.isWired() ? "wiredDpiIndex" : "wirelessDpiIndex"; + const after = await this.updateConfig({ [key]: stage }); + const applied = this.isWired() ? after.wiredDpiIndex : after.wirelessDpiIndex; + if (applied !== stage) throw new Error("The mouse did not accept the new DPI stage."); + } + + async setDpiStageValue(stage: number, dpi: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= MCHOSE_DPI_STAGES) { + throw new Error(`DPI stage must be 0-${MCHOSE_DPI_STAGES - 1}.`); + } + const max = this.cachedProduct?.dpiMax ?? 26000; + if (dpi < DPI_MIN || dpi > max) throw new Error(`DPI must be between ${DPI_MIN} and ${max}.`); + const rounded = Math.round(dpi / DPI_STEP) * DPI_STEP; + + const before = await this.readConfigPayload(); + const current = before ? mchoseDecodeConfig(before) : null; + if (!current) throw new Error("The mouse did not return its configuration."); + const stages = [...current.dpiStages]; + stages[stage] = rounded; + + const after = await this.updateConfig({ dpiStages: stages }); + if (after.dpiStages[stage] !== rounded) { + throw new Error("The mouse did not accept the new DPI value."); + } + } + + /** + * Switch the active onboard profile. `profile` is 1-based to match what the + * shell displays; the firmware is 0-based. + * + * Each profile carries its own DPI stages and per-link rate, so everything + * the panel shows changes with it — the caller is expected to re-read status + * afterwards. The device also needs a moment before it will answer for the + * newly selected profile, hence the settle below. + */ + async setProfile(profile: number): Promise { + if (!Number.isInteger(profile) || profile < 1 || profile > MCHOSE_PROFILE_COUNT) { + throw new Error(`Profile must be 1-${MCHOSE_PROFILE_COUNT}.`); + } + await this.open(); + const body = mchoseEncodeCommand(mchoseEncodeSetProfile(profile - 1), MCHOSE_SHORT_TOKENS); + const send = async (): Promise => { + await this.device.sendFeatureReport(MCHOSE_SHORT_REPORT_ID, body); + await delay(PROFILE_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readConfigPayload(); + const decoded = after ? mchoseDecodeConfig(after) : null; + if (!decoded) throw new Error("The mouse did not confirm the profile change."); + if (decoded.profileIndex !== profile - 1) { + throw new Error("The mouse did not switch to that profile."); + } + } + +/** + * Lift-off and the processing toggles share one command and one status byte, + * so both go through here rather than `updateConfig`. They still read back + * out of the config blob, which is what confirms the change. + */ + private async writePerformance( + liftOffIndex: number, + changes: Partial & { mode?: number; angleTuning?: number }, + applied: (config: MchoseConfig) => boolean, + ): Promise { + await this.open(); + const body = mchoseEncodeCommand( + mchoseEncodePerformance(liftOffIndex, changes), + MCHOSE_SHORT_TOKENS, + ); + + // This command is the slowest of the lot, and a read taken too soon comes + // back with the *previous* state rather than failing — which reads as the + // change being rejected. Re-send and re-read until the value actually + // reflects what was asked for. + let last: MchoseConfig | null = null; + for (let attempt = 0; attempt < PERFORMANCE_ATTEMPTS; attempt += 1) { + const send = async (): Promise => { + await this.device.sendFeatureReport(MCHOSE_SHORT_REPORT_ID, body); + await delay(PERFORMANCE_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readConfigPayload(); + const decoded = after ? mchoseDecodeConfig(after) : null; + if (decoded) { + last = decoded; + if (applied(decoded)) return decoded; + } + } + if (!last) throw new Error("The mouse did not confirm the change."); + return last; + } + + /** The current lift-off step, needed because the command always carries it. */ + private async currentLiftOffIndex(): Promise { + const payload = await this.readConfigPayload(); + const config = payload ? mchoseDecodeConfig(payload) : null; + if (!config) throw new Error("The mouse did not return its configuration."); + return config.liftOffIndex; + } + + async setLiftOffDistance(label: NonNullable): Promise { + const steps = this.cachedProduct?.liftOffDistances.length ?? 3; + const index = mchoseLiftOffLabels(steps).indexOf(label); + if (index < 0) throw new Error(`This mouse does not offer a ${label} lift-off distance.`); + const after = await this.writePerformance(index, {}, (c) => c.liftOffIndex === index); + if (after.liftOffIndex !== index) { + throw new Error("The mouse did not accept that lift-off distance."); + } + } + + async setMotionSync(enabled: boolean): Promise { + const after = await this.writePerformance(await this.currentLiftOffIndex(), { motionSync: enabled }, (c) => c.processing.motionSync === enabled); + if (after.processing.motionSync !== enabled) { + throw new Error("The mouse did not accept the motion sync change."); + } + } + + async setAngleSnapping(enabled: boolean): Promise { + const after = await this.writePerformance(await this.currentLiftOffIndex(), { angleSnapping: enabled }, (c) => c.processing.angleSnapping === enabled); + if (after.processing.angleSnapping !== enabled) { + throw new Error("The mouse did not accept the angle snapping change."); + } + } + + async setRippleControl(enabled: boolean): Promise { + const after = await this.writePerformance(await this.currentLiftOffIndex(), { rippleControl: enabled }, (c) => c.processing.rippleControl === enabled); + if (after.processing.rippleControl !== enabled) { + throw new Error("The mouse did not accept the ripple control change."); + } + } + + /** The named power/performance modes this mouse offers. */ + getPowerModes(): string[] { + return [...MCHOSE_MODES]; + } + + /** + * MCHOSE's mode selector — Performance, eSports or Ultra. It is a three-way + * choice, not a switch: the two bits live at the top of the same `sensor` + * byte as lift-off and the processing toggles. + */ + async setPowerMode(name: string): Promise { + const mode = mchoseModeNumber(name); + if (mode === null) throw new Error(`This mouse has no "${name}" mode.`); + const after = await this.writePerformance( + await this.currentLiftOffIndex(), + { mode }, + (config) => config.mode === name, + ); + if (after.mode !== name) throw new Error("The mouse did not accept the mode change."); + } + + /** Angle tuning in degrees; 0 turns the correction off. */ + async setAngleTuning(degrees: number): Promise { + const value = Math.round(degrees); + if (!Number.isInteger(value) || value < MCHOSE_ANGLE_TUNING_MIN || value > MCHOSE_ANGLE_TUNING_MAX) { + throw new Error(`Angle tuning must be ${MCHOSE_ANGLE_TUNING_MIN} to ${MCHOSE_ANGLE_TUNING_MAX} degrees.`); + } + const after = await this.writePerformance(await this.currentLiftOffIndex(), { angleTuning: value }, (c) => c.angleTuning === value); + if (after.angleTuning !== value) { + throw new Error("The mouse did not accept the angle tuning change."); + } + } + + /** + * How many of the six DPI stages the mouse cycles through. Stored in the + * config blob, so it goes through the ordinary read-modify-write. + */ + async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > MCHOSE_DPI_STAGES) { + throw new Error(`DPI stage count must be 1-${MCHOSE_DPI_STAGES}.`); + } + const after = await this.updateConfig({ stageCount: count }); + if (after.stageCount !== count) { + throw new Error("The mouse did not accept the new DPI stage count."); + } + } + + /** + * The three profile names the mouse stores. Read one at a time; a profile + * whose name will not come back is left out rather than guessed at, and the + * caller falls back to numbering. + */ + private async readProfileNames(): Promise { + const names: string[] = []; + for (let index = 0; index < MCHOSE_PROFILE_COUNT; index += 1) { + const payload = await this.request( + MCHOSE_LONG_REPORT_ID, + MCHOSE_COMMAND.readProfileName, + MCHOSE_LONG_TOKENS, + (reply) => reply[0] === index && (reply[1] ?? 0) !== 0, + [index], + ); + const name = payload ? mchoseDecodeProfileName(payload) : null; + if (!name) return null; + names.push(name); + } + return names; + } + + /** + * Name whichever buttons carry a macro. A macro's name is the only thing + * about it this driver can surface — recording one is not implemented — so + * labelling it beats showing a bare "Macro". Buttons without one answer with + * a zero-length name and are left alone. + */ + private async labelMacros(buttons: MchoseButtonAssignment[]): Promise { + for (const button of buttons) { + if (button.type !== MCHOSE_BUTTON_TYPE.macro) continue; + const payload = await this.request( + MCHOSE_LONG_REPORT_ID, + MCHOSE_COMMAND.readButtonName, + MCHOSE_LONG_TOKENS, + (reply) => reply[0] === button.buttonIndex, + [button.buttonIndex], + ); + const name = payload ? mchoseDecodeButtonName(payload, button.buttonIndex) : null; + if (name) button.label = `Macro: ${name}`; + } + } + + /** Every action a button can be assigned, for the picker. */ + getButtonOptions(): string[] { + return MCHOSE_BUTTON_ACTIONS.map((entry) => entry.label); + } + + /** + * Reassign one button. This is a standalone command rather than part of the + * config blob, so it cannot disturb DPI, polling or the other buttons — + * confirmed on hardware, where only the target button's four bytes moved. + * + * "Default" restores the factory function, which is the firmware's own escape + * hatch (type 0 with a zero value). + */ + async setButtonMapping(button: string, actionLabel: string): Promise { + const buttonIndex = MCHOSE_BUTTONS.indexOf(button); + if (buttonIndex < 0) throw new Error(`This mouse has no "${button}" button.`); + const action = mchoseFindButtonAction(actionLabel); + if (!action) throw new Error(`Unknown button action "${actionLabel}".`); + + await this.open(); + const tokens = mchoseEncodeButton(buttonIndex, action.type, action.value); + const body = mchoseEncodeCommand(tokens, MCHOSE_LONG_TOKENS); + const send = async (): Promise => { + await this.device.sendFeatureReport(MCHOSE_LONG_REPORT_ID, body); + await delay(BUTTON_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readConfigPayload(); + const assignments = after ? mchoseDecodeButtons(after) : null; + const applied = assignments?.[buttonIndex]; + if (!applied) throw new Error("The mouse did not confirm the button change."); + if (applied.type !== action.type || applied.value !== action.value) { + throw new Error("The mouse did not accept that button assignment."); + } + } + + /** Sleep timeouts this driver offers, in seconds. 0 disables the timer. */ + getSleepOptions(): number[] { + return [...MCHOSE_SLEEP_OPTIONS]; + } + + getDebounceMaxMs(): number { + return MCHOSE_DEBOUNCE_MAX_MS; + } + + /** The firmware stores whole minutes; the panel works in seconds. */ + async setSleepTimeout(seconds: number): Promise { + const minutes = Math.round(Math.max(0, seconds) / 60); + await this.open(); + const body = mchoseEncodeCommand(mchoseEncodeSleep(minutes), MCHOSE_SHORT_TOKENS); + const send = async (): Promise => { + await this.device.sendFeatureReport(MCHOSE_SHORT_REPORT_ID, body); + await delay(SLEEP_SETTLE_MS); + }; + const queued = this.queue.then(send, send); + this.queue = queued.catch(() => undefined); + await queued; + + const after = await this.readConfigPayload(); + const decoded = after ? mchoseDecodeConfig(after) : null; + if (!decoded) throw new Error("The mouse did not confirm the sleep timeout."); + if (decoded.sleep !== minutes) throw new Error("The mouse did not accept that sleep timeout."); + } + + /** Debounce rides along in the config blob rather than its own command. */ + async setDebounceTime(milliseconds: number): Promise { + if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > MCHOSE_DEBOUNCE_MAX_MS) { + throw new Error(`Debounce must be 0-${MCHOSE_DEBOUNCE_MAX_MS} ms.`); + } + const after = await this.updateConfig({ keyDebounce: milliseconds }); + if (after.keyDebounce !== milliseconds) { + throw new Error("The mouse did not accept the new debounce time."); + } + } + + /** Sets the DPI of whichever stage the live connection is using. */ + async setDpi(dpi: number): Promise { + const payload = await this.readConfigPayload(); + const current = payload ? mchoseDecodeConfig(payload) : null; + if (!current) throw new Error("The mouse did not return its configuration."); + await this.setDpiStageValue( + this.isWired() ? current.wiredDpiIndex : current.wirelessDpiIndex, + dpi, + ); + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..1d5a658 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -118,7 +118,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; @@ -138,6 +138,31 @@ export interface MouseStatus { pollingRateHz: number; supportedPollingRates?: number[]; activeProfile: number | null; + /** + * How many onboard profiles the device exposes, when it has a simple + * numbered set the user can switch between (distinct from Logitech's + * onboard-profile editor). Set together with a 1-based `activeProfile` and a + * `setProfile(index)` method, and the shared profile selector appears. + */ + profileCount?: number; + /** + * Current button assignments, keyed by physical button name. Set together + * with `buttonOptions` and a `setButtonMapping(button, action)` method, and + * the shared button remapper appears. Distinct from the Razer and Endgame + * fields above, which predate this and carry brand-specific shapes. + */ + buttonMappings?: Record; + /** + * Named power/performance modes a device offers as a single choice, with + * `powerMode` holding the current one and a `setPowerMode(name)` method. + * Distinct from the boolean `performanceMode` above, which is a switch. + */ + powerModes?: string[]; + powerMode?: string; + /** Names for each onboard profile, when the device stores them. */ + profileNames?: string[]; + /** Every action `setButtonMapping` will accept, in display order. */ + buttonOptions?: string[]; deviceMode?: "Onboard" | "Host" | "Unknown"; unitId?: string | null; modelId?: string | null; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80d7219..b44e87c 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -40,9 +40,11 @@ import { SteelSeriesPrimeMiniWirelessHidClient } from "./steelseries/prime-mini- import { SteelSeriesSenseiTenHidClient } from "./steelseries/sensei-ten-hid.ts"; import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; +import { MchoseHidClient } from "./mchose/hid.ts"; +import { MchoseDockHidClient } from "./mchose/dock-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | MchoseHidClient | MchoseDockHidClient; export interface DeviceDriver { brand: string; @@ -94,6 +96,8 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesSenseiTenHidClient.isSupported(device), create: (device) => new SteelSeriesSenseiTenHidClient(device), score: () => 6 }, { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, { brand: "Glorious", supports: (device) => GloriousClassicHidClient.isSupported(device), create: (device) => new GloriousClassicHidClient(device), score: () => 5 }, + { brand: "MCHOSE", supports: (device) => MchoseHidClient.isSupported(device), create: (device) => new MchoseHidClient(device), score: () => 7 }, + { brand: "MCHOSE", supports: (device) => MchoseDockHidClient.isSupported(device), create: (device) => new MchoseDockHidClient(device), score: () => 7 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..5b46379 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -1,5 +1,12 @@ import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { GWOLVES_PRODUCTS } from "./gwolves/products.ts"; +import { + MCHOSE_CONFIG_USAGE, + MCHOSE_CONFIG_USAGE_PAGE, + MCHOSE_DOCK_PRODUCT_ID, + MCHOSE_DOCK_USAGE, + MCHOSE_DOCK_USAGE_PAGE, +} from "@openmouse/protocol/mchose"; import { LOGITECH_BOLT_PRODUCT_IDS, LOGITECH_DIRECT_PRODUCT_IDS, @@ -77,6 +84,7 @@ export const VENDOR_ID = { gloriousClassicI: 0x22d4, // original Model I gloriousClassicIWired: 0x320f, // Model O V2 / Model I 2 wired gloriousO3: 0x3794, // Model O3 Wireless / receiver (newer CORE-v2 generation) + mchose: 0x3837, } as const; /** @@ -397,6 +405,11 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ // interfaces that lack the feature-report-0 control channel. { vendorId: VENDOR_ID.lamzu }, { vendorId: VENDOR_ID.orbital, usagePage: 0xff0a, usage: 1 }, + // MCHOSE ships keyboards and audio devices under 0x3837 too, so this stays + // narrowed to the mouse configuration collection rather than the whole VID. + { vendorId: VENDOR_ID.mchose, usagePage: MCHOSE_CONFIG_USAGE_PAGE, usage: MCHOSE_CONFIG_USAGE }, + // The MagDock is a separate device on its own usage page; it carries the RGB. + { vendorId: VENDOR_ID.mchose, productId: MCHOSE_DOCK_PRODUCT_ID, usagePage: MCHOSE_DOCK_USAGE_PAGE, usage: MCHOSE_DOCK_USAGE }, ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, productId })), ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, diff --git a/src/mchose/buttons.test.ts b/src/mchose/buttons.test.ts new file mode 100644 index 0000000..4f7abb2 --- /dev/null +++ b/src/mchose/buttons.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MCHOSE_BUTTONS, + MCHOSE_BUTTON_ACTIONS, + MCHOSE_BUTTON_TYPE, + mchoseDecodeButtons, + mchoseDescribeButton, + mchoseEncodeButton, + mchoseFindButtonAction, + mchoseEncodeReadButtonName, + mchoseDecodeButtonName, + MCHOSE_READ_BUTTON_NAME, +} from "./buttons.ts"; + +/** + * The button table as read from a real A7 V2 Ultra+: left/middle/right/DPI on + * their factory functions, and forward/back mapped to keyboard F9 and F10. + */ +const CONFIG_WITH_BUTTONS = new Uint8Array(64); +CONFIG_WITH_BUTTONS.set([0x00, 0x30, 0x20, 0x00, 0x40, 0x06], 0); +CONFIG_WITH_BUTTONS.set([ + 0x00, 0x00, 0x00, 0x00, // 0 left type 0 + 0x10, 0x00, 0x00, 0x00, // 1 middle type 0 + 0x20, 0x00, 0x00, 0x00, // 2 right type 0 + 0x32, 0x00, 0x42, 0x00, // 3 forward type 2, 0x004200 = F9 + 0x42, 0x00, 0x43, 0x00, // 4 back type 2, 0x004300 = F10 + 0x50, 0x00, 0x00, 0x00, // 5 DPI type 0 +], 20); + +test("the six buttons decode in the order the blob stores them", () => { + const buttons = mchoseDecodeButtons(CONFIG_WITH_BUTTONS); + assert.ok(buttons); + assert.equal(buttons.length, 6); + assert.deepEqual(buttons.map((b) => b.buttonIndex), [0, 1, 2, 3, 4, 5]); + assert.deepEqual(MCHOSE_BUTTONS, ["Left", "Middle", "Right", "Forward", "Back", "DPI"]); +}); + +test("the captured mapping names the keyboard keys behind forward and back", () => { + const buttons = mchoseDecodeButtons(CONFIG_WITH_BUTTONS)!; + assert.deepEqual(buttons.map((b) => b.label), [ + "Default", "Default", "Default", "F9", "F10", "Default", + ]); +}); + +test("type and value must be matched together, since values collide", () => { + // 0x010000 is left-click as a mouse action but "DPI switch" as a DPI action. + assert.equal(mchoseDescribeButton(MCHOSE_BUTTON_TYPE.mouse, 0x010000), "Left click"); + assert.equal(mchoseDescribeButton(MCHOSE_BUTTON_TYPE.dpi, 0x010000), "DPI switch"); + assert.equal(mchoseDescribeButton(MCHOSE_BUTTON_TYPE.profile, 0x010000), "Switch to profile 1"); +}); + +test("a macro and an unrecognised assignment are still named", () => { + assert.equal(mchoseDescribeButton(MCHOSE_BUTTON_TYPE.macro, 0x123456), "Macro"); + assert.match(mchoseDescribeButton(6, 0xabcdef), /^Unknown \(type 6, 0xabcdef\)$/); +}); + +test("default is the only action carrying a zero value", () => { + const zeroed = MCHOSE_BUTTON_ACTIONS.filter((entry) => entry.value === 0); + assert.deepEqual(zeroed.map((entry) => entry.label), ["Default"]); + assert.equal(zeroed[0]!.type, MCHOSE_BUTTON_TYPE.default); +}); + +test("every action label is unique, since the picker keys on it", () => { + const labels = MCHOSE_BUTTON_ACTIONS.map((entry) => entry.label); + assert.equal(new Set(labels).size, labels.length); +}); + +test("the write is a standalone command with a 24-bit big-endian value", () => { + const tokens = mchoseEncodeButton(3, MCHOSE_BUTTON_TYPE.media, 0xcd0000); + assert.deepEqual(tokens, [0x52, 3, 0, MCHOSE_BUTTON_TYPE.media, 0xcd, 0x00, 0x00]); +}); + +test("resetting a button to default sends type 0 and value 0", () => { + const action = mchoseFindButtonAction("Default")!; + assert.deepEqual( + mchoseEncodeButton(0, action.type, action.value), + [0x52, 0, 0, 0, 0, 0, 0], + ); +}); + +test("the write refuses a button this mouse does not have", () => { + assert.throws(() => mchoseEncodeButton(6, 1, 0), /0-5/); + assert.throws(() => mchoseEncodeButton(-1, 1, 0), /0-5/); +}); + +test("actions round-trip from label to bytes and back to label", () => { + for (const entry of MCHOSE_BUTTON_ACTIONS) { + const tokens = mchoseEncodeButton(0, entry.type, entry.value); + const value = (tokens[4]! << 16) | (tokens[5]! << 8) | tokens[6]!; + assert.equal(value, entry.value, `${entry.label} value survives encoding`); + assert.equal(mchoseDescribeButton(entry.type, value), entry.label); + } +}); + +test("mchoseFindButtonAction rejects an unknown label", () => { + assert.equal(mchoseFindButtonAction("Launch rocket"), null); +}); + +test("decoding rejects a payload too short to hold the table", () => { + assert.equal(mchoseDecodeButtons(CONFIG_WITH_BUTTONS.subarray(0, 30)), null); +}); + +test("a macro's name is read per button and echoes its index", () => { + assert.deepEqual(mchoseEncodeReadButtonName(3), [MCHOSE_READ_BUTTON_NAME, 3]); + assert.throws(() => mchoseEncodeReadButtonName(6), /0-5/); + + // "Spray" stored on button 3. + const named = new Uint8Array([3, 5, 0x53, 0x70, 0x72, 0x61, 0x79]); + assert.equal(mchoseDecodeButtonName(named, 3), "Spray"); + // A reply for a different button must never be adopted. + assert.equal(mchoseDecodeButtonName(named, 4), null); +}); + +test("a button with no macro reports no name", () => { + // What every button on the test hardware answered: index echoed, length 0. + assert.equal(mchoseDecodeButtonName(new Uint8Array([0, 0, 0, 0]), 0), null); + assert.equal(mchoseDecodeButtonName(new Uint8Array([2, 99, 0x41]), 2), null, "length beyond the payload"); +}); diff --git a/src/mchose/buttons.ts b/src/mchose/buttons.ts new file mode 100644 index 0000000..76bb258 --- /dev/null +++ b/src/mchose/buttons.ts @@ -0,0 +1,220 @@ +/** + * MCHOSE button-remapping vocabulary. + * + * A button assignment is a `type` nibble plus a 24-bit big-endian value, and + * the type selects which table the value is looked up in — the same value means + * different things under different types (`0x010000` is left-click under type 1 + * but "DPI switch" under type 5). All of it is transcribed from M HUB's own + * tables; see docs/mchose-protocol.md. + */ + +export const MCHOSE_BUTTON_TYPE = { + /** Restore the button's factory function. Always paired with value 0. */ + default: 0, + mouse: 1, + keyboard: 2, + media: 3, + macro: 4, + dpi: 5, + system: 8, + disable: 9, + profile: 10, +} as const; + +/** Physical buttons, in the order the config blob stores them. */ +export const MCHOSE_BUTTONS: readonly string[] = [ + "Left", "Middle", "Right", "Forward", "Back", "DPI", +]; + +export interface MchoseButtonAction { + label: string; + type: number; + value: number; + group: string; +} + +const action = (group: string, type: number) => + (label: string, value: number): MchoseButtonAction => ({ label, type, value, group }); + +const mouse = action("Mouse", MCHOSE_BUTTON_TYPE.mouse); +const key = action("Keyboard", MCHOSE_BUTTON_TYPE.keyboard); +const media = action("Media", MCHOSE_BUTTON_TYPE.media); +const dpi = action("DPI", MCHOSE_BUTTON_TYPE.dpi); +const system = action("System", MCHOSE_BUTTON_TYPE.system); +const profile = action("Profile", MCHOSE_BUTTON_TYPE.profile); + +/** Keyboard entries, as `label` -> low 16 bits; the high byte is the modifier. */ +const KEYS: ReadonlyArray = [ + ["A", 0x0400], ["B", 0x0500], ["C", 0x0600], ["D", 0x0700], ["E", 0x0800], + ["F", 0x0900], ["G", 0x0a00], ["H", 0x0b00], ["I", 0x0c00], ["J", 0x0d00], + ["K", 0x0e00], ["L", 0x0f00], ["M", 0x1000], ["N", 0x1100], ["O", 0x1200], + ["P", 0x1300], ["Q", 0x1400], ["R", 0x1500], ["S", 0x1600], ["T", 0x1700], + ["U", 0x1800], ["V", 0x1900], ["W", 0x1a00], ["X", 0x1b00], ["Y", 0x1c00], + ["Z", 0x1d00], + ["1", 0x1e00], ["2", 0x1f00], ["3", 0x2000], ["4", 0x2100], ["5", 0x2200], + ["6", 0x2300], ["7", 0x2400], ["8", 0x2500], ["9", 0x2600], ["0", 0x2700], + ["F1", 0x3a00], ["F2", 0x3b00], ["F3", 0x3c00], ["F4", 0x3d00], ["F5", 0x3e00], + ["F6", 0x3f00], ["F7", 0x4000], ["F8", 0x4100], ["F9", 0x4200], ["F10", 0x4300], + ["F11", 0x4400], ["F12", 0x4500], + ["Esc", 0x2900], ["Tab", 0x2b00], ["Space", 0x2c00], ["Enter", 0x2800], + ["Backspace", 0x2a00], ["Delete", 0x4c00], ["Insert", 0x4900], + ["Home", 0x4a00], ["End", 0x4d00], ["Page Up", 0x4b00], ["Page Down", 0x4e00], + ["Up", 0x5200], ["Down", 0x5100], ["Left", 0x5000], ["Right", 0x4f00], + ["Caps Lock", 0x3900], ["Num Lock", 0x5300], ["Scroll Lock", 0x4700], + ["Print Screen", 0x4600], ["Pause", 0x4800], ["Menu", 0x6500], + ["Left Ctrl", 0xe000], ["Left Shift", 0xe100], ["Left Alt", 0xe200], + ["Left Windows", 0xe300], ["Right Ctrl", 0xe400], ["Right Shift", 0xe500], + ["Right Alt", 0xe600], ["Right Windows", 0xe700], +]; + +/** Shortcuts, where the top byte carries the modifier bitmask. */ +const SHORTCUTS: ReadonlyArray = [ + ["Ctrl + A", 0x010400], ["Ctrl + C", 0x010600], ["Ctrl + N", 0x011100], + ["Ctrl + O", 0x011200], ["Ctrl + S", 0x011600], ["Ctrl + T", 0x011700], + ["Ctrl + V", 0x011900], ["Ctrl + W", 0x011a00], ["Ctrl + X", 0x011b00], + ["Ctrl + Y", 0x011c00], ["Ctrl + Z", 0x011d00], ["Ctrl + Esc", 0x012900], + ["Ctrl + Shift + Esc", 0x032900], + ["Alt + Tab", 0x042b00], ["Alt + F4", 0x043d00], ["Alt + Esc", 0x042900], + ["Alt + Left", 0x045000], ["Alt + Right", 0x044f00], + ["Win + D", 0x080700], ["Win + E", 0x080800], ["Win + L", 0x080f00], + ["Win + R", 0x081500], ["Win + S", 0x081600], ["Win + Tab", 0x082b00], +]; + +/** + * Everything a button can be set to. "Default" restores the factory function + * and is the only entry whose value must be zero. + */ +export const MCHOSE_BUTTON_ACTIONS: readonly MchoseButtonAction[] = [ + { label: "Default", type: MCHOSE_BUTTON_TYPE.default, value: 0, group: "Basic" }, + { label: "Disabled", type: MCHOSE_BUTTON_TYPE.disable, value: 0xffffff, group: "Basic" }, + + mouse("Left click", 0x010000), + mouse("Right click", 0x020000), + mouse("Middle click", 0x040000), + mouse("Forward", 0x100000), + mouse("Back", 0x080000), + mouse("Wheel up", 0x000200), + mouse("Wheel down", 0x00fe00), + + dpi("DPI switch", 0x010000), + dpi("DPI +", 0x020000), + dpi("DPI -", 0x030000), + + media("Play / Pause", 0xcd0000), + media("Next track", 0xb50000), + media("Previous track", 0xb60000), + media("Stop", 0xb70000), + media("Volume +", 0xe90000), + media("Volume -", 0xea0000), + media("Mute", 0xe20000), + + system("Copy", 0x070106), + system("Cut", 0x07011b), + system("Paste", 0x070119), + system("Screen brightness +", 0x0c6f00), + system("Screen brightness -", 0x0c7000), + + profile("Switch to profile 1", 0x010000), + profile("Switch to profile 2", 0x020000), + profile("Switch to profile 3", 0x030000), + profile("Cycle profiles", 0x040000), + + ...KEYS.map(([label, value]) => key(label, value)), + ...SHORTCUTS.map(([label, value]) => key(label, value)), +]; + +/** Look up an action by its display label. */ +export function mchoseFindButtonAction(label: string): MchoseButtonAction | null { + return MCHOSE_BUTTON_ACTIONS.find((entry) => entry.label === label) ?? null; +} + +/** + * Name a stored assignment. Type and value must be matched together, since the + * same value means different things under different types. + */ +export function mchoseDescribeButton(type: number, value: number): string { + if (type === MCHOSE_BUTTON_TYPE.default) return "Default"; + if (type === MCHOSE_BUTTON_TYPE.macro) return "Macro"; + const found = MCHOSE_BUTTON_ACTIONS.find( + (entry) => entry.type === type && entry.value === value, + ); + if (found) return found.label; + return `Unknown (type ${type}, 0x${value.toString(16).padStart(6, "0")})`; +} + +/** Six four-byte entries starting at this offset of the 0x67 config payload. */ +export const MCHOSE_BUTTON_TABLE_OFFSET = 20; +export const MCHOSE_BUTTON_ENTRY_LENGTH = 4; + +export interface MchoseButtonAssignment { + /** Which physical button this entry belongs to, from the high nibble. */ + buttonIndex: number; + type: number; + value: number; + label: string; +} + +export function mchoseDecodeButtons(payload: Uint8Array): MchoseButtonAssignment[] | null { + const end = MCHOSE_BUTTON_TABLE_OFFSET + MCHOSE_BUTTONS.length * MCHOSE_BUTTON_ENTRY_LENGTH; + if (payload.length < end) return null; + return MCHOSE_BUTTONS.map((_, index) => { + const at = MCHOSE_BUTTON_TABLE_OFFSET + index * MCHOSE_BUTTON_ENTRY_LENGTH; + const header = payload[at] ?? 0; + const type = header & 0x0f; + const value = ((payload[at + 1] ?? 0) << 16) + | ((payload[at + 2] ?? 0) << 8) + | (payload[at + 3] ?? 0); + return { + buttonIndex: (header >> 4) & 0x0f, + type, + value, + label: mchoseDescribeButton(type, value), + }; + }); +} + +/** + * Build the `0x52` write. Unlike DPI and polling this is a standalone command, + * not part of the config blob, so it only ever touches one button. + */ +export function mchoseEncodeButton( + buttonIndex: number, + type: number, + value: number, +): number[] { + if (!Number.isInteger(buttonIndex) || buttonIndex < 0 || buttonIndex >= MCHOSE_BUTTONS.length) { + throw new Error(`Button index must be 0-${MCHOSE_BUTTONS.length - 1}.`); + } + return [ + 0x52, + buttonIndex, + 0, + type & 0x0f, + (value >> 16) & 0xff, + (value >> 8) & 0xff, + value & 0xff, + ]; +} + +/** + * Per-button name, read with `0x12 0x63 `. A button carrying a macro + * stores the macro's name here; every other button answers with a zero length. + * The reply echoes the button index, which is what makes it safe to match. + */ +export const MCHOSE_READ_BUTTON_NAME = 0x63; +const MCHOSE_BUTTON_NAME_MAX = 20; + +export function mchoseEncodeReadButtonName(buttonIndex: number): number[] { + if (!Number.isInteger(buttonIndex) || buttonIndex < 0 || buttonIndex >= MCHOSE_BUTTONS.length) { + throw new Error(`Button index must be 0-${MCHOSE_BUTTONS.length - 1}.`); + } + return [MCHOSE_READ_BUTTON_NAME, buttonIndex]; +} + +export function mchoseDecodeButtonName(payload: Uint8Array, buttonIndex: number): string | null { + if (payload.length < 2 || payload[0] !== buttonIndex) return null; + const size = payload[1] ?? 0; + if (size === 0 || size > MCHOSE_BUTTON_NAME_MAX || payload.length < 2 + size) return null; + const name = String.fromCharCode(...payload.subarray(2, 2 + size)).trim(); + return name.length ? name : null; +} diff --git a/src/mchose/dock.test.ts b/src/mchose/dock.test.ts new file mode 100644 index 0000000..de0c404 --- /dev/null +++ b/src/mchose/dock.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MCHOSE_DOCK_COMMAND, + MCHOSE_DOCK_EFFECT, + MCHOSE_DOCK_FRAME_LENGTH, + MCHOSE_DOCK_PAYLOAD_OFFSET, + mchoseDockColorFromHex, + mchoseDockColorToHex, + mchoseDockDecodeLighting, + mchoseDockEffectFor, + mchoseDockEncode, + mchoseDockEncodeLighting, + mchoseDockModeLabel, + mchoseDockPayload, +} from "./dock.ts"; + +/** + * Captured from a real MCHOSE MagDock: the reply to the lighting read, with the + * base on mode 3 (colour cycling) at brightness 2 and a red base colour. + */ +const REPLY = new Uint8Array(63); +REPLY.set([0xaa, 0x07, 0x02, 0x00, 0x00, 0x1e], 0); +REPLY.set([0x01, 0x03, 0x06, 0x02, 0x02, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00], 6); + +test("a request frame carries the dock's plain, non-inverted header", () => { + const frame = mchoseDockEncode(MCHOSE_DOCK_COMMAND.readLighting); + assert.equal(frame.length, MCHOSE_DOCK_FRAME_LENGTH); + assert.equal(frame[0], 0xaa, "start byte"); + assert.equal(frame[1], MCHOSE_DOCK_COMMAND.readLighting); + assert.equal(frame[2], 0, "request"); + assert.equal(frame[5], 0, "no parameters"); + // Unlike the mouse, nothing here is XOR'd. + assert.equal(frame[6], 0x00); +}); + +test("parameters land after the six-byte header", () => { + const frame = mchoseDockEncode(MCHOSE_DOCK_COMMAND.writeLighting, [1, 2, 3]); + assert.equal(frame[5], 3, "parameter length"); + assert.deepEqual([...frame.subarray(6, 9)], [1, 2, 3]); +}); + +test("a reply is only accepted for the command that was sent", () => { + assert.ok(mchoseDockPayload(REPLY, MCHOSE_DOCK_COMMAND.readLighting)); + assert.equal(mchoseDockPayload(REPLY, MCHOSE_DOCK_COMMAND.writeLighting), null); + const wrongStart = Uint8Array.from(REPLY); + wrongStart[0] = 0x55; + assert.equal(mchoseDockPayload(wrongStart, MCHOSE_DOCK_COMMAND.readLighting), null); + assert.equal(mchoseDockPayload(new Uint8Array(4), MCHOSE_DOCK_COMMAND.readLighting), null); +}); + +test("the captured lighting state decodes field for field", () => { + const payload = mchoseDockPayload(REPLY, MCHOSE_DOCK_COMMAND.readLighting)!; + const state = mchoseDockDecodeLighting(payload); + assert.deepEqual(state, { + enabled: true, + effect: MCHOSE_DOCK_EFFECT.cycling, + effectCount: 6, + speed: 2, + brightness: 2, + musicSync: false, + color: [255, 0, 0], + direction: 0, + }); +}); + +test("decoding rejects a payload too short to hold the block", () => { + assert.equal(mchoseDockDecodeLighting(new Uint8Array(10)), null); +}); + +test("effects map onto the shared lighting labels", () => { + assert.equal(mchoseDockModeLabel(MCHOSE_DOCK_EFFECT.static), "Static"); + assert.equal(mchoseDockModeLabel(MCHOSE_DOCK_EFFECT.cycling), "Cycling"); + assert.equal(mchoseDockModeLabel(MCHOSE_DOCK_EFFECT.flow), "Wave"); + assert.equal(mchoseDockEffectFor("Breathing single"), MCHOSE_DOCK_EFFECT.breathing); + assert.equal(mchoseDockEffectFor("Reactive"), MCHOSE_DOCK_EFFECT.music); + assert.equal(mchoseDockEffectFor("Nonexistent"), null); +}); + +test("an unknown effect id falls back rather than throwing", () => { + assert.equal(mchoseDockModeLabel(99), "Static"); +}); + +test("the write sends every field, since there is no partial update", () => { + const payload = mchoseDockPayload(REPLY, MCHOSE_DOCK_COMMAND.readLighting)!; + const state = mchoseDockDecodeLighting(payload)!; + const frame = mchoseDockEncodeLighting({ ...state, brightness: 4 }); + assert.equal(frame[1], MCHOSE_DOCK_COMMAND.writeLighting); + assert.equal(frame[5], 10, "ten parameters"); + const params = [...frame.subarray(MCHOSE_DOCK_PAYLOAD_OFFSET, MCHOSE_DOCK_PAYLOAD_OFFSET + 10)]; + assert.deepEqual(params, [1, 3, 6, 2, 4, 0, 255, 0, 0, 0]); +}); + +test("a decoded state re-encodes to the same parameters", () => { + const payload = mchoseDockPayload(REPLY, MCHOSE_DOCK_COMMAND.readLighting)!; + const state = mchoseDockDecodeLighting(payload)!; + const frame = mchoseDockEncodeLighting(state); + const params = [...frame.subarray(MCHOSE_DOCK_PAYLOAD_OFFSET, MCHOSE_DOCK_PAYLOAD_OFFSET + 9)]; + assert.deepEqual(params, [...payload.subarray(0, 9)], "round-trips the captured block"); +}); + +test("colours convert both ways", () => { + assert.equal(mchoseDockColorToHex([255, 0, 0]), "#ff0000"); + assert.equal(mchoseDockColorToHex([0, 255, 8]), "#00ff08"); + assert.deepEqual(mchoseDockColorFromHex("#00ff08"), [0, 255, 8]); + assert.deepEqual(mchoseDockColorFromHex("00FF08"), [0, 255, 8]); + assert.equal(mchoseDockColorFromHex("not a colour"), null); +}); diff --git a/src/mchose/dock.ts b/src/mchose/dock.ts new file mode 100644 index 0000000..c4f71f6 --- /dev/null +++ b/src/mchose/dock.ts @@ -0,0 +1,156 @@ +/** + * MCHOSE MagDock — the magnetic charging base, which carries the RGB the mouse + * itself does not have. + * + * It is a **separate device with a separate protocol**: product id `0x1012` on + * usage page `0xff00`, a WCH controller rather than the mouse's RealTek, and + * plain (non-inverted) frames on unnumbered output report 0: + * + * [0] 0xaa start + * [1] command + * [2] command type — 0 request, 2 response + * [3] frame sequence + * [4] total frames + * [5] parameter length + * [6…] parameters + * + * Replies use the same layout, so a reply's payload begins at offset 6. + */ + +export const MCHOSE_DOCK_PRODUCT_ID = 0x1012; +export const MCHOSE_DOCK_USAGE_PAGE = 0xff00; +export const MCHOSE_DOCK_USAGE = 0x0001; + +/** Unnumbered report: the id byte is 0. */ +export const MCHOSE_DOCK_REPORT_ID = 0; +export const MCHOSE_DOCK_FRAME_LENGTH = 63; +export const MCHOSE_DOCK_PAYLOAD_OFFSET = 6; + +const START = 0xaa; +const REQUEST = 0; +const RESPONSE = 2; + +export const MCHOSE_DOCK_COMMAND = { + readLighting: 7, + writeLighting: 39, +} as const; + +export function mchoseDockEncode(command: number, params: readonly number[] = []): Uint8Array { + const frame = new Uint8Array(MCHOSE_DOCK_FRAME_LENGTH); + frame[0] = START; + frame[1] = command; + frame[2] = REQUEST; + frame[3] = 0; + frame[4] = 0; + frame[5] = params.length; + params.forEach((value, index) => { frame[MCHOSE_DOCK_PAYLOAD_OFFSET + index] = value & 0xff; }); + return frame; +} + +/** Payload of a reply to `command`, or null when this is not that reply. */ +export function mchoseDockPayload(raw: Uint8Array, command: number): Uint8Array | null { + if (raw.length <= MCHOSE_DOCK_PAYLOAD_OFFSET) return null; + if (raw[0] !== START || raw[1] !== command) return null; + if (raw[2] !== RESPONSE && raw[2] !== REQUEST) return null; + return raw.subarray(MCHOSE_DOCK_PAYLOAD_OFFSET); +} + +/** + * Effect ids, from MCHOSE's own enum + * (`{static:0, breath:1, shiningBrightly:2, loop:3, goFlow:4, music:5}`). + */ +export const MCHOSE_DOCK_EFFECT = { + static: 0, + breathing: 1, + shining: 2, + cycling: 3, + flow: 4, + music: 5, +} as const; + +/** + * The shared `MouseLightingMode` union has no MCHOSE-specific names, so each + * effect is mapped to its nearest shared label. "Shining" and "music" have no + * exact counterpart — they are approximated by Spectrum and Reactive, which is + * how they read in the panel. + */ +export const MCHOSE_DOCK_MODE_LABELS: ReadonlyArray = [ + [MCHOSE_DOCK_EFFECT.static, "Static"], + [MCHOSE_DOCK_EFFECT.breathing, "Breathing single"], + [MCHOSE_DOCK_EFFECT.shining, "Spectrum"], + [MCHOSE_DOCK_EFFECT.cycling, "Cycling"], + [MCHOSE_DOCK_EFFECT.flow, "Wave"], + [MCHOSE_DOCK_EFFECT.music, "Reactive"], +]; + +/** Effects that actually use the base colour; the rest animate their own. */ +export const MCHOSE_DOCK_COLOR_MODES: readonly string[] = ["Static", "Breathing single"]; + +/** Brightness and speed are both 0-4 in the protocol. */ +export const MCHOSE_DOCK_LEVELS: readonly number[] = [0, 1, 2, 3, 4]; + +export interface MchoseDockLighting { + enabled: boolean; + effect: number; + /** Count of effects the firmware reports; echoed back on write. */ + effectCount: number; + speed: number; + brightness: number; + musicSync: boolean; + color: [number, number, number]; + direction: number; +} + +export function mchoseDockDecodeLighting(payload: Uint8Array): MchoseDockLighting | null { + if (payload.length < 30) return null; + return { + enabled: payload[0] === 1, + effect: payload[1] ?? 0, + effectCount: payload[2] ?? 0, + speed: payload[3] ?? 0, + brightness: payload[4] ?? 0, + musicSync: payload[5] === 1, + color: [payload[6] ?? 0, payload[7] ?? 0, payload[8] ?? 0], + direction: payload[29] ?? 0, + }; +} + +/** + * The write takes every field at once — there is no partial update — so a + * caller changing one thing must supply the rest as they were. + */ +export function mchoseDockEncodeLighting(state: MchoseDockLighting): Uint8Array { + return mchoseDockEncode(MCHOSE_DOCK_COMMAND.writeLighting, [ + state.enabled ? 1 : 0, + state.effect, + state.effectCount, + state.speed, + state.brightness, + state.musicSync ? 1 : 0, + state.color[0], + state.color[1], + state.color[2], + state.direction, + ]); +} + +export function mchoseDockModeLabel(effect: number): string { + return MCHOSE_DOCK_MODE_LABELS.find(([id]) => id === effect)?.[1] ?? "Static"; +} + +export function mchoseDockEffectFor(label: string): number | null { + return MCHOSE_DOCK_MODE_LABELS.find(([, name]) => name === label)?.[0] ?? null; +} + +const clampByte = (value: number): number => Math.max(0, Math.min(255, Math.round(value))); + +export function mchoseDockColorToHex(color: readonly [number, number, number]): string { + return `#${color.map((c) => clampByte(c).toString(16).padStart(2, "0")).join("")}`; +} + +export function mchoseDockColorFromHex(hex: string): [number, number, number] | null { + const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return null; + const value = parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} diff --git a/src/mchose/index.test.ts b/src/mchose/index.test.ts new file mode 100644 index 0000000..79b464f --- /dev/null +++ b/src/mchose/index.test.ts @@ -0,0 +1,337 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MCHOSE_COMMAND, + MCHOSE_LONG_REPORT_ID, + MCHOSE_LONG_TOKENS, + MCHOSE_SHORT_TOKENS, + mchoseDecodeBattery, + mchoseDecodeConfig, + mchoseDecodeIdentity, + mchoseDecodeReply, + mchoseDecodeVersion, + mchoseEncodeCommand, + mchoseEncodeConfigWrite, + mchoseFindProduct, + mchosePackLinkByte, + MCHOSE_POLLING_RATES, + MCHOSE_PROFILE_COUNT, + mchoseEncodeSetProfile, + mchoseDecodeLiftOffIndex, + mchoseDecodeProcessing, + mchoseDecodeMode, + mchoseModeName, + mchoseModeNumber, + mchoseDecodeAngleTuning, + mchoseEncodeAngleTuning, + mchoseEncodePerformance, + mchoseEncodeLiftOff, + mchoseLiftOffLabels, + mchosePollingRates, + MCHOSE_LINK_PRODUCT_IDS, +} from "./index.ts"; + +/** + * Every byte string below was captured from a real MCHOSE A7 V2 Ultra+ on its + * 2.4 GHz receiver (0x3837:0x100b), with mchose-research/dump.mjs. They are the + * plain (already un-inverted) reply payloads. + */ +const bytes = (hex: string): Uint8Array => + new Uint8Array(hex.trim().split(/\s+/).map((b) => parseInt(b, 16))); + +const IDENTITY = bytes("01 37 38 0b 10 01 00"); +const VERSION = bytes("08 35 2e 34 36 2e 32 2e 34"); +const BATTERY = bytes("37 38 21 40 05 2e 02 04 09 29 00 2c"); +const CONFIG = bytes("00 30 20 00 40 06 20 03 40 06 80 0c 00 19 10 a4 01 80 00 00"); + +test("commands are sent bit-inverted, with the rest of the report left zero", () => { + const body = mchoseEncodeCommand([MCHOSE_COMMAND.battery], MCHOSE_SHORT_TOKENS); + assert.equal(body.length, 64); + assert.equal(body[0], 0xf9, "command 0x06 inverted"); + assert.equal(body[1], 0xff, "spelled zero argument inverted"); + assert.equal(body[MCHOSE_SHORT_TOKENS - 1], 0xff, "last spelled token still inverted"); + assert.equal(body[MCHOSE_SHORT_TOKENS], 0x00, "padding past the spelled tokens stays zero"); +}); + +test("long commands invert all 64 tokens", () => { + const body = mchoseEncodeCommand([MCHOSE_COMMAND.config], MCHOSE_LONG_TOKENS); + assert.equal(body[0], 0x98, "command 0x67 inverted"); + assert.equal(body[63], 0xff); +}); + +test("a reply is un-inverted behind its report-id byte", () => { + // [reportId, ~cmd, ~payload…] + const raw = new Uint8Array([MCHOSE_LONG_REPORT_ID, 0x98, 0xff, 0xcf]); + const reply = mchoseDecodeReply(raw); + assert.ok(reply); + assert.equal(reply.command, MCHOSE_COMMAND.config); + assert.deepEqual([...reply.payload], [0x00, 0x30]); +}); + +test("mchoseDecodeReply rejects a truncated read", () => { + assert.equal(mchoseDecodeReply(new Uint8Array([0x11, 0x98])), null); +}); + +test("identity decodes the receiver ids and link state", () => { + const identity = mchoseDecodeIdentity(IDENTITY); + assert.deepEqual(identity, { + bonded: true, + vendorId: 0x3837, + productId: 0x100b, + connected: true, + gameMode: 0, + }); +}); + +test("version decodes the length-prefixed string the Ultra+ reports", () => { + assert.equal(mchoseDecodeVersion(VERSION), "5.46.2.4"); +}); + +test("version rejects a zero-length or truncated string", () => { + assert.equal(mchoseDecodeVersion(new Uint8Array([0x00])), null); + assert.equal(mchoseDecodeVersion(new Uint8Array([0x08, 0x35])), null); +}); + +test("battery reports the mouse's own id, not the receiver's", () => { + const battery = mchoseDecodeBattery(BATTERY); + assert.ok(battery); + assert.equal(battery.vendorId, 0x3837); + assert.equal(battery.productId, 0x4021, "the A7 V2 Ultra+ behind the 0x100b receiver"); + assert.equal(battery.batteryPercent, 41); + assert.equal(battery.charging, false); +}); + +test("config decodes the DPI stage table past the reserved byte", () => { + const config = mchoseDecodeConfig(CONFIG); + assert.ok(config); + assert.equal(config.profileIndex, 0); + assert.deepEqual(config.dpiStages, [1600, 800, 1600, 3200, 6400, 42000]); +}); + +/** + * The same mouse read at two known settings, which is what pins the nibble + * order. Byte 1 is the wired link, byte 2 the wireless one; in each, the rate + * is the high nibble and the DPI stage the low one. + */ +test("config splits the per-link index nibbles the way hardware does", () => { + // Captured while the mouse was on its receiver at 1000 Hz and 1600 DPI. + const config = mchoseDecodeConfig(CONFIG); + assert.ok(config); + assert.equal(config.wirelessRateIndex, 2, "0x20 high nibble -> rate index 2"); + assert.equal(config.wirelessDpiIndex, 0, "0x20 low nibble -> stage 0"); + assert.equal(config.wiredRateIndex, 3, "0x30 high nibble"); + assert.equal(config.wiredDpiIndex, 0); +}); + +test("the wireless indices agree with the settings the device was set to", () => { + const config = mchoseDecodeConfig(CONFIG); + assert.ok(config); + const rates = MCHOSE_POLLING_RATES[8000]!; + assert.equal(rates[config.wirelessRateIndex], 1000, "the mouse was set to 1000 Hz"); + assert.equal(config.dpiStages[config.wirelessDpiIndex], 1600, "and to 1600 DPI"); +}); + +test("the earlier 125 Hz capture decodes to rate index 0", () => { + // Same mouse before the polling rate was changed: wireless byte 0x00. + const at125 = Uint8Array.from(CONFIG); + at125[2] = 0x00; + const config = mchoseDecodeConfig(at125); + assert.ok(config); + assert.equal(config.wirelessRateIndex, 0); + assert.equal(MCHOSE_POLLING_RATES[8000]![config.wirelessRateIndex], 125); +}); + +test("config rejects a payload too short to hold the stage table", () => { + assert.equal(mchoseDecodeConfig(CONFIG.subarray(0, 12)), null); +}); + +test("the mouse id identifies the model, since host PIDs are shared", () => { + assert.equal(mchoseFindProduct(0x4021)?.name, "A7 V2 Ultra+"); + assert.equal(mchoseFindProduct(0x4019)?.name, "A7 V2 Ultra"); + assert.equal(mchoseFindProduct(0x4018)?.dpiMax, 26000); +}); + +test("the product string disambiguates when the mouse id is unknown", () => { + assert.equal(mchoseFindProduct(null, "MCHOSE A7 V2 Ultra+")?.name, "A7 V2 Ultra+"); + // The plus model must not be swallowed by the shorter name. + assert.equal(mchoseFindProduct(null, "MCHOSE A7 V2 Ultra")?.name, "A7 V2 Ultra"); + assert.equal(mchoseFindProduct(null, "Some Other Mouse"), null); + assert.equal(mchoseFindProduct(null, null), null); +}); + +test("a config write echoes every byte it is not changing", () => { + const tokens = mchoseEncodeConfigWrite(CONFIG, { wirelessRateIndex: 1 }); + assert.equal(tokens[0], MCHOSE_COMMAND.writeConfig, "0x57 leads the write"); + // The command byte shifts everything by one; only the wireless byte moved. + assert.equal(tokens[1 + 2], 0x10, "rate index 1, stage 0 preserved"); + assert.equal(tokens[1 + 1], CONFIG[1], "the wired byte is untouched"); + for (let index = 3; index < CONFIG.length; index += 1) { + assert.equal(tokens[1 + index], CONFIG[index], `byte ${index} echoed unchanged`); + } +}); + +test("a stage rewrite keeps the other five stages intact", () => { + const stages = [1600, 800, 1600, 3200, 6400, 42000]; + stages[1] = 12000; + const tokens = mchoseEncodeConfigWrite(CONFIG, { dpiStages: stages }); + // Stage 1 lives at payload offset 6-7, so offset 7-8 once shifted. + assert.equal(tokens[1 + 6], 12000 & 0xff); + assert.equal(tokens[1 + 7], (12000 >> 8) & 0xff); + assert.equal(tokens[1 + 4], 0x40, "stage 0 low byte unchanged"); + assert.equal(tokens[1 + 14], 0x10, "stage 5 low byte unchanged"); +}); + +test("packing a link byte is the inverse of decoding it", () => { + assert.equal(mchosePackLinkByte(2, 0), 0x20); + assert.equal(mchosePackLinkByte(3, 0), 0x30); + const round = mchoseDecodeConfig(CONFIG)!; + assert.equal(mchosePackLinkByte(round.wirelessRateIndex, round.wirelessDpiIndex), CONFIG[2]); + assert.equal(mchosePackLinkByte(round.wiredRateIndex, round.wiredDpiIndex), CONFIG[1]); +}); + +test("a write refuses a payload it cannot decode", () => { + assert.throws(() => mchoseEncodeConfigWrite(CONFIG.subarray(0, 8))); +}); + +test("the profile command carries a 0-based index", () => { + assert.deepEqual(mchoseEncodeSetProfile(0), [MCHOSE_COMMAND.setProfile, 0]); + assert.deepEqual(mchoseEncodeSetProfile(2), [MCHOSE_COMMAND.setProfile, 2]); +}); + +test("the profile command refuses an index the mouse does not have", () => { + assert.throws(() => mchoseEncodeSetProfile(-1), /0-2/); + assert.throws(() => mchoseEncodeSetProfile(MCHOSE_PROFILE_COUNT), /0-2/); + assert.throws(() => mchoseEncodeSetProfile(1.5), /0-2/); +}); + +test("each profile carries its own link settings", () => { + // Profiles 0 and 2 as captured from the same mouse, one after the other. + const profile0 = mchoseDecodeConfig(bytes("00 30 30 00 40 06 20 03 40 06 80 0c 00 19 10 a4 01 80 00 00"))!; + const profile2 = mchoseDecodeConfig(bytes("02 32 22 00 40 06 20 03 40 06 80 0c 00 19 10 a4 01 80 00 00"))!; + assert.equal(profile0.profileIndex, 0); + assert.equal(profile2.profileIndex, 2); + const rates = MCHOSE_POLLING_RATES[8000]!; + assert.equal(rates[profile0.wirelessRateIndex], 2000); + assert.equal(rates[profile2.wirelessRateIndex], 1000); + assert.equal(profile0.dpiStages[profile0.wirelessDpiIndex], 1600); + assert.equal(profile2.dpiStages[profile2.wirelessDpiIndex], 1600); +}); + +test("lift-off decodes out of the low bits of the sensor byte", () => { + // Captured: sensor 0x80 at the lowest step, 0x81 and 0x82 after writing 1/2. + assert.equal(mchoseDecodeLiftOffIndex(0x80), 0); + assert.equal(mchoseDecodeLiftOffIndex(0x81), 1); + assert.equal(mchoseDecodeLiftOffIndex(0x82), 2); + assert.equal(mchoseDecodeConfig(CONFIG)!.liftOffIndex, 0); +}); + +test("the sensor byte's upper bits are not part of lift-off", () => { + const raised = Uint8Array.from(CONFIG); + raised[17] = 0x82; + const config = mchoseDecodeConfig(raised)!; + assert.equal(config.liftOffIndex, 2); + assert.equal(config.sensor, 0x82, "the whole byte is still exposed for writers to preserve"); +}); + +test("the lift-off write leaves the performance toggles at zero", () => { + // [command, lod, ripple, line, motionSync, _, _, gameMode, rotateOpen, + // rotateVal] — every zero means "leave this one alone". + assert.deepEqual( + mchoseEncodeLiftOff(2), + [MCHOSE_COMMAND.setPerformance, 2, 0, 0, 0, 0, 0, 0, 0, 0], + ); + const encoded = mchoseEncodeCommand(mchoseEncodeLiftOff(2), MCHOSE_SHORT_TOKENS); + assert.equal(encoded[0], (~MCHOSE_COMMAND.setPerformance) & 0xff); + assert.equal(encoded[1], (~2) & 0xff); + assert.equal(encoded[2], 0xff, "ripple left untouched"); + assert.equal(encoded[4], 0xff, "motion sync left untouched"); + assert.equal(encoded[7], 0xff, "game mode left untouched"); + assert.equal(encoded[8], 0xff, "angle tuning not applied"); +}); + +test("the sensor byte carries the processing toggles alongside lift-off", () => { + // Mapped on hardware: 0x80 -> 0x90 -> 0x94 -> 0x9c as each was switched on. + assert.deepEqual(mchoseDecodeProcessing(0x00), { + angleSnapping: false, rippleControl: false, motionSync: false, + }); + assert.equal(mchoseDecodeProcessing(0x90).motionSync, true); + assert.equal(mchoseDecodeProcessing(0x94).rippleControl, true); + assert.equal(mchoseDecodeProcessing(0x9c).angleSnapping, true); +}); + +test("bits 6-7 are a three-way mode, not a boolean", () => { + // Written on hardware as field values 1, 2 and 3 in turn. + assert.equal(mchoseModeName(0x00), "Performance"); + assert.equal(mchoseModeName(0x80), "eSports"); + assert.equal(mchoseModeName(0xc0), "Ultra"); + // The stored pattern is the mode number, except Performance stores 0. + assert.equal(mchoseDecodeMode(0x00), 1); + assert.equal(mchoseDecodeMode(0x80), 2); + assert.equal(mchoseDecodeMode(0xc0), 3); + // The mode must not bleed into lift-off or the toggles. + assert.equal(mchoseDecodeLiftOffIndex(0xc2), 2); + assert.equal(mchoseDecodeProcessing(0xc0).motionSync, false); +}); + +test("mode names round-trip to the numbers the firmware wants", () => { + assert.equal(mchoseModeNumber("Performance"), 1); + assert.equal(mchoseModeNumber("eSports"), 2); + assert.equal(mchoseModeNumber("Ultra"), 3); + assert.equal(mchoseModeNumber("Turbo"), null); + assert.deepEqual(mchoseEncodePerformance(0, { mode: 3 })[7], 3); + assert.equal(mchoseEncodePerformance(0, {})[7], 0, "untouched when not named"); + assert.throws(() => mchoseEncodePerformance(0, { mode: 4 }), /1-3/); +}); + +test("angle tuning is signed, spanning the -30 to +30 the vendor offers", () => { + assert.equal(mchoseEncodeAngleTuning(15), 0x0f); + assert.equal(mchoseEncodeAngleTuning(-15), 0xf1, "two's complement"); + assert.equal(mchoseDecodeAngleTuning(0xf1), -15); + assert.equal(mchoseDecodeAngleTuning(0x0f), 15); + assert.equal(mchoseDecodeAngleTuning(0), 0); + assert.throws(() => mchoseEncodePerformance(0, { angleTuning: 31 }), /-30 to 30/); + assert.throws(() => mchoseEncodePerformance(0, { angleTuning: -31 }), /-30 to 30/); + assert.equal(mchoseEncodePerformance(0, { angleTuning: -15 })[9], 0xf1); +}); + +test("ripple's bit is not mistaken for part of the lift-off level", () => { + // 0x84 is lift-off 0 with ripple on; a three-bit mask would read level 4. + assert.equal(mchoseDecodeLiftOffIndex(0x84), 0); + assert.equal(mchoseDecodeProcessing(0x84).rippleControl, true); + assert.equal(mchoseDecodeLiftOffIndex(0x9e), 2, "level 2 with every toggle on"); +}); + +test("a toggle write carries lift-off and only the toggles named", () => { + assert.deepEqual( + mchoseEncodePerformance(1, { motionSync: true }), + [MCHOSE_COMMAND.setPerformance, 1, 0, 0, 1, 0, 0, 0, 0, 0], + ); + assert.deepEqual( + mchoseEncodePerformance(0, { rippleControl: false, angleSnapping: true }), + [MCHOSE_COMMAND.setPerformance, 0, 2, 1, 0, 0, 0, 0, 0, 0], + ); +}); + +test("lift-off labels follow how many steps the model has", () => { + assert.deepEqual(mchoseLiftOffLabels(3), ["Low", "Medium", "High"]); + // The Pro and Pro+ only offer 1 mm and 2 mm. + assert.deepEqual(mchoseLiftOffLabels(2), ["Low", "High"]); +}); + +test("the lift-off write refuses an index outside the field", () => { + assert.throws(() => mchoseEncodeLiftOff(-1), /0-3/); + assert.throws(() => mchoseEncodeLiftOff(4), /0-3/); +}); + +test("Bluetooth is capped at 1000 Hz even on an 8K model", () => { + const ultraPlus = mchoseFindProduct(0x4021); + assert.ok(ultraPlus); + assert.deepEqual( + mchosePollingRates(ultraPlus, MCHOSE_LINK_PRODUCT_IDS.receiver), + [125, 500, 1000, 2000, 4000, 8000], + ); + assert.deepEqual( + mchosePollingRates(ultraPlus, MCHOSE_LINK_PRODUCT_IDS.bluetooth), + [125, 500, 1000], + ); +}); diff --git a/src/mchose/index.ts b/src/mchose/index.ts new file mode 100644 index 0000000..f4ba70a --- /dev/null +++ b/src/mchose/index.ts @@ -0,0 +1,562 @@ +/** + * Pure MCHOSE codecs, unit-tested without WebHID. + * + * Reverse-engineered from MCHOSE's own M HUB web driver and verified against an + * A7 V2 Ultra+ on its 2.4 GHz receiver; see docs/mchose-protocol.md for the + * capture notes and the dead ends. + * + * Transport: feature reports on the `0xff01` vendor collection. Report `0x11` + * carries the short (20-byte) command set, report `0x12` the long (64-byte) + * one. The distinguishing quirk is that **the whole command body is sent + * bit-inverted, and replies come back inverted too**: + * + * sendFeatureReport(0x11, [~cmd, ~arg0, …]) + * receiveFeatureReport(0x11) -> [reportId, ~cmd, ~payload0, …] + * + * Only the bytes the vendor actually spells out are inverted; the rest of the + * report is zero-padded, which is why replies carry unrelated trailing bytes — + * the firmware leaves stale scratch data past the meaningful fields. + */ + +export const MCHOSE_VENDOR_ID = 0x3837; + +/** Vendor configuration collection. */ +export const MCHOSE_CONFIG_USAGE_PAGE = 0xff01; +export const MCHOSE_CONFIG_USAGE = 0x0001; + +/** Short command channel: command + 19 argument bytes. */ +export const MCHOSE_SHORT_REPORT_ID = 0x11; +export const MCHOSE_SHORT_TOKENS = 20; +/** Long command channel: command + 63 argument bytes. */ +export const MCHOSE_LONG_REPORT_ID = 0x12; +export const MCHOSE_LONG_TOKENS = 64; + +export const MCHOSE_REPORT_LENGTH = 64; + +export const MCHOSE_COMMAND = { + /** Receiver identity: bond flag, ids, link state. */ + identity: 0x03, + /** Firmware version, as a length-prefixed ASCII string. */ + version: 0x04, + /** Mouse identity plus battery level and charge state. */ + battery: 0x06, + /** Whole configuration blob: DPI stages, per-link indices, debounce, sleep. */ + config: 0x67, + /** Writes the same blob back, one command byte in front of the same layout. */ + writeConfig: 0x57, + /** Switches the active onboard profile; takes a single 0-based index. */ + setProfile: 0x58, + /** Reassigns one button: `[buttonIndex, reserved, type, value u24 BE]`. */ + setButton: 0x52, + /** Reads one button's name, which is where a macro's name lives. */ + readButtonName: 0x63, + /** Reads one profile's name: `[index]`, replying index + ASCII. */ + readProfileName: 0x68, + /** Sets the auto-sleep timer: `[enabled, minutes]`. */ + setSleep: 0x0a, + /** + * Performance block: `[lod, ripple, line, motionSync, _, _, mode, + * rotateOpen, rotateVal]`. + */ + setPerformance: 0x42, +} as const; + +/** + * The config blob's `sensor` byte (offset 17) is a bitfield holding lift-off + * distance *and* the three processing toggles. Every one of these is written + * through the performance command rather than the config write, and read back + * out of this byte. Mapped bit by bit on hardware: + * + * 0x80 -> 0x90 motion sync on (bit 4) + * 0x90 -> 0x94 ripple control on (bit 2) + * 0x94 -> 0x9c linear correction on (bit 3) + * + * Bits 6-7 are the performance mode — see `MCHOSE_SENSOR_MODE_MASK` below. An + * earlier reading of this byte took bit 7 alone to be a boolean "game mode", + * which is wrong: M HUB offers three modes, and bit 6 is the other half. + * + * Lift-off therefore occupies only **bits 0-1**; treating it as three bits + * would read ripple as part of the level. Bit 5 is still unexplained, so + * writers must preserve the rest of the byte rather than assign it. + */ +export const MCHOSE_LOD_MASK = 0x03; +export const MCHOSE_SENSOR_RIPPLE = 0x04; +export const MCHOSE_SENSOR_LINEAR = 0x08; +export const MCHOSE_SENSOR_MOTION_SYNC = 0x10; + +/** + * Bits 6-7 hold the **performance mode**, which M HUB presents as a three-way + * choice rather than a switch. Mapped on hardware by writing each value: + * + * field 7 = 1 -> bits 00 -> Performance + * field 7 = 2 -> bits 10 -> eSports + * field 7 = 3 -> bits 11 -> Ultra + * + * Note the gap: the stored bit pattern is the mode number except for + * Performance, which stores 0 rather than 1. + */ +export const MCHOSE_SENSOR_MODE_MASK = 0xc0; + +export const MCHOSE_MODES: readonly string[] = ["Performance", "eSports", "Ultra"]; + +/** Decode bits 6-7 into a 1-based mode number. */ +export function mchoseDecodeMode(sensor: number): number { + const stored = (sensor & MCHOSE_SENSOR_MODE_MASK) >> 6; + return stored === 0 ? 1 : stored; +} + +export function mchoseModeName(sensor: number): string { + return MCHOSE_MODES[mchoseDecodeMode(sensor) - 1] ?? MCHOSE_MODES[0]!; +} + +export function mchoseModeNumber(name: string): number | null { + const index = MCHOSE_MODES.indexOf(name); + return index < 0 ? null : index + 1; +} + +/** + * Angle tuning, in the config blob at offset 49. M HUB offers -30 to +30 + * degrees; the byte is stored as-is, so negatives are two's complement. The + * firmware does not validate the value — it stored 0xf1 and 0x8f unchanged — + * so the range has to be enforced here. + * + * M HUB flags this control with "update the mouse firmware", so older firmware + * may ignore it. + */ +export const MCHOSE_ANGLE_TUNING_OFFSET = 49; +export const MCHOSE_ANGLE_TUNING_MIN = -30; +export const MCHOSE_ANGLE_TUNING_MAX = 30; + +export function mchoseDecodeAngleTuning(raw: number): number { + const byte = raw & 0xff; + return byte > 0x7f ? byte - 0x100 : byte; +} + +export function mchoseEncodeAngleTuning(degrees: number): number { + return degrees < 0 ? (degrees + 0x100) & 0xff : degrees & 0xff; +} + +export function mchoseDecodeLiftOffIndex(sensor: number): number { + return sensor & MCHOSE_LOD_MASK; +} + +export interface MchoseProcessing { + /** MCHOSE calls this "linear correction"; it is angle snapping. */ + angleSnapping: boolean; + rippleControl: boolean; + motionSync: boolean; +} + +export function mchoseDecodeProcessing(sensor: number): MchoseProcessing { + return { + angleSnapping: (sensor & MCHOSE_SENSOR_LINEAR) !== 0, + rippleControl: (sensor & MCHOSE_SENSOR_RIPPLE) !== 0, + motionSync: (sensor & MCHOSE_SENSOR_MOTION_SYNC) !== 0, + }; +} + +/** + * Ripple, linear correction and motion sync use 1 = on and 2 = off, with 0 + * meaning "leave this one alone". + */ +const toggle = (value: boolean | undefined): number => + value === undefined ? 0 : value ? 1 : 2; + +/** + * Build the performance write. + * + * Lift-off must be supplied every time, because index 0 is a real level and so + * cannot double as "unchanged" the way the toggles' 0 does. Pass only the + * toggles being changed and the rest are left as they are. + */ +export function mchoseEncodePerformance( + liftOffIndex: number, + changes: Partial & { mode?: number; angleTuning?: number } = {}, +): number[] { + if (!Number.isInteger(liftOffIndex) || liftOffIndex < 0 || liftOffIndex > MCHOSE_LOD_MASK) { + throw new Error(`Lift-off index must be 0-${MCHOSE_LOD_MASK}.`); + } + const tuning = changes.angleTuning; + if (tuning !== undefined + && (!Number.isInteger(tuning) || tuning < MCHOSE_ANGLE_TUNING_MIN || tuning > MCHOSE_ANGLE_TUNING_MAX)) { + throw new Error(`Angle tuning must be ${MCHOSE_ANGLE_TUNING_MIN} to ${MCHOSE_ANGLE_TUNING_MAX} degrees.`); + } + const mode = changes.mode; + if (mode !== undefined && (!Number.isInteger(mode) || mode < 1 || mode > MCHOSE_MODES.length)) { + throw new Error(`Mode must be 1-${MCHOSE_MODES.length}.`); + } + // [command, lod, ripple, line, motionSync, _, _, mode, rotateOpen, rotateVal] + return [ + MCHOSE_COMMAND.setPerformance, + liftOffIndex, + toggle(changes.rippleControl), + toggle(changes.angleSnapping), + toggle(changes.motionSync), + 0, + 0, + mode ?? 0, + tuning === undefined ? 0 : 1, + tuning === undefined ? 0 : mchoseEncodeAngleTuning(tuning), + ]; +} + +/** Lift-off on its own, leaving every processing toggle untouched. */ +export function mchoseEncodeLiftOff(index: number): number[] { + return mchoseEncodePerformance(index); +} + +/** The shared three-stop labels, positioned by how many steps a model offers. */ +export type MchoseLiftOffLabel = "Low" | "Medium" | "High"; + +export function mchoseLiftOffLabels(steps: number): MchoseLiftOffLabel[] { + if (steps >= 3) return ["Low", "Medium", "High"]; + if (steps === 2) return ["Low", "High"]; + return ["Low"]; +} + +/** + * Auto-sleep is stored in whole minutes at offset 19 of the config blob, and + * written with its own command rather than through the config write. Confirmed + * on hardware: sending `0x0a 01 09` moved that byte from 0 to 9. + * + * The device is slower to apply this than a config write — it needs roughly a + * second before the new value reads back. + */ +export const MCHOSE_SLEEP_MAX_MINUTES = 0xff; + +/** Sleep timeouts offered in the UI, in seconds; 0 disables the timer. */ +export const MCHOSE_SLEEP_OPTIONS: readonly number[] = [0, 60, 120, 180, 300, 600, 1800]; + +/** Longest debounce the firmware accepts, in milliseconds. */ +export const MCHOSE_DEBOUNCE_MAX_MS = 20; + +export function mchoseEncodeSleep(minutes: number): number[] { + if (!Number.isInteger(minutes) || minutes < 0 || minutes > MCHOSE_SLEEP_MAX_MINUTES) { + throw new Error(`Sleep must be 0-${MCHOSE_SLEEP_MAX_MINUTES} minutes.`); + } + // The vendor pairs a "sleep enabled" flag with the value; 0 minutes is off. + return [MCHOSE_COMMAND.setSleep, minutes > 0 ? 1 : 0, minutes]; +} + +/** + * Onboard profiles, 0-based on the wire. The mouse holds three, each with its + * own DPI stage table and its own per-link rate/stage pair — confirmed by + * switching an A7 V2 Ultra+ through all three and reading each back. + * + * `0x67` has no profile argument: it always answers for whichever profile is + * active, so reading another one means switching to it first. + */ +export const MCHOSE_PROFILE_COUNT = 3; + +/** Build the `0x58` payload. `index` is 0-based, as the firmware expects. */ +export function mchoseEncodeSetProfile(index: number): number[] { + if (!Number.isInteger(index) || index < 0 || index >= MCHOSE_PROFILE_COUNT) { + throw new Error(`MCHOSE profile index must be 0-${MCHOSE_PROFILE_COUNT - 1}.`); + } + return [MCHOSE_COMMAND.setProfile, index]; +} + +/** + * Build a feature-report body. `tokens` is the command byte followed by its + * arguments, exactly as the vendor spells them; everything given is inverted + * and the remainder of the report stays zero. + */ +export function mchoseEncodeCommand( + tokens: readonly number[], + tokenCount: number, +): Uint8Array { + const body = new Uint8Array(MCHOSE_REPORT_LENGTH); + const spelled = Math.min(tokenCount, MCHOSE_REPORT_LENGTH); + for (let index = 0; index < spelled; index += 1) { + body[index] = ((tokens[index] ?? 0) ^ 0xff) & 0xff; + } + return body; +} + +export interface MchoseReply { + command: number; + payload: Uint8Array; +} + +/** + * Decode a feature-report read. `raw` is the buffer as returned by + * `receiveFeatureReport`, whose first byte is the report id. + */ +export function mchoseDecodeReply(raw: Uint8Array): MchoseReply | null { + if (raw.length < 3) return null; + const payload = new Uint8Array(raw.length - 2); + for (let index = 0; index < payload.length; index += 1) { + payload[index] = ((raw[index + 2] ?? 0) ^ 0xff) & 0xff; + } + return { command: (~(raw[1] ?? 0)) & 0xff, payload }; +} + +const u16 = (data: Uint8Array, offset: number): number => + (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8); + +// ── 0x11 0x03 identity ───────────────────────────────────────────────────── + +export interface MchoseIdentity { + bonded: boolean; + vendorId: number; + productId: number; + connected: boolean; + gameMode: number; +} + +export function mchoseDecodeIdentity(payload: Uint8Array): MchoseIdentity | null { + if (payload.length < 7) return null; + return { + bonded: (payload[0] ?? 0) !== 0, + vendorId: u16(payload, 1), + productId: u16(payload, 3), + connected: (payload[5] ?? 0) !== 0, + gameMode: payload[6] ?? 0, + }; +} + +// ── 0x11 0x04 firmware version ───────────────────────────────────────────── + +/** Length-prefixed ASCII, e.g. `08 "5.46.2.4"`. */ +export function mchoseDecodeVersion(payload: Uint8Array): string | null { + const length = payload[0] ?? 0; + if (!length || payload.length < 1 + length) return null; + return String.fromCharCode(...payload.subarray(1, 1 + length)); +} + +// ── 0x11 0x06 battery ────────────────────────────────────────────────────── + +export interface MchoseBattery { + vendorId: number; + /** The mouse's own product id, even when read through the receiver. */ + productId: number; + firmwareVersion: number; + connectMode: number; + connected: boolean; + batteryPercent: number; + charging: boolean; +} + +export function mchoseDecodeBattery(payload: Uint8Array): MchoseBattery | null { + if (payload.length < 11) return null; + // Bit fields are packed most-significant first: 3 bits mode, 1 bit status. + const flags = payload[8] ?? 0; + return { + vendorId: u16(payload, 0), + productId: u16(payload, 2), + firmwareVersion: u16(payload, 4) | (u16(payload, 6) << 16), + connectMode: (flags >> 5) & 0x07, + connected: ((flags >> 4) & 0x01) !== 0, + batteryPercent: payload[9] ?? 0, + charging: (payload[10] ?? 0) !== 0, + }; +} + +// ── 0x12 0x67 configuration ──────────────────────────────────────────────── + +export const MCHOSE_DPI_STAGES = 6; + +export interface MchoseConfig { + profileIndex: number; + /** Active DPI stage and polling index on the 2.4 GHz link. */ + wirelessDpiIndex: number; + wirelessRateIndex: number; + /** Active DPI stage and polling index on the wired link. */ + wiredDpiIndex: number; + wiredRateIndex: number; + /** Six DPI stages, little-endian uint16 each. */ + dpiStages: number[]; + stageCount: number; + sensor: number; + /** Lift-off step, decoded out of the low bits of `sensor`. */ + liftOffIndex: number; + /** Processing toggles, decoded out of the same `sensor` byte. */ + processing: MchoseProcessing; + keyDebounce: number; + sleep: number; + /** Angle-tuning degrees at offset 49; 0 is no correction. */ + angleTuning: number; + /** Performance mode name, from bits 6-7 of `sensor`. */ + mode: string; +} + +/** + * Note the reserved byte at offset 3: the DPI stages start at offset 4, not + * immediately after the two index bytes. + * + * MCHOSE ships two schemas for this payload and they contradict each other. + * The layout below is the one the hardware actually uses, confirmed on an + * A7 V2 Ultra+ by reading it at two known settings: + * + * 125 Hz / 1600 DPI -> byte2 0x00 (rate index 0, stage 0) + * 1000 Hz / 1600 DPI -> byte2 0x20 (rate index 2, stage 0) + * + * So the **wired** byte comes first, the **rate** is the high nibble, and the + * rate is a plain index into the model's rate list with no skipped value. That + * matches the vendor's write schema; its read schema has both the byte order + * and the nibble order backwards. Do not "correct" this to match the latter. + */ +export function mchoseDecodeConfig(payload: Uint8Array): MchoseConfig | null { + if (payload.length < 4 + MCHOSE_DPI_STAGES * 2) return null; + const wired = payload[1] ?? 0; + const wireless = payload[2] ?? 0; + const dpiStages: number[] = []; + for (let stage = 0; stage < MCHOSE_DPI_STAGES; stage += 1) { + dpiStages.push(u16(payload, 4 + stage * 2)); + } + return { + profileIndex: payload[0] ?? 0, + wirelessDpiIndex: wireless & 0x0f, + wirelessRateIndex: (wireless >> 4) & 0x0f, + wiredDpiIndex: wired & 0x0f, + wiredRateIndex: (wired >> 4) & 0x0f, + dpiStages, + stageCount: payload[16] ?? 0, + sensor: payload[17] ?? 0, + liftOffIndex: mchoseDecodeLiftOffIndex(payload[17] ?? 0), + processing: mchoseDecodeProcessing(payload[17] ?? 0), + keyDebounce: payload[18] ?? 0, + sleep: payload[19] ?? 0, + angleTuning: mchoseDecodeAngleTuning(payload[MCHOSE_ANGLE_TUNING_OFFSET] ?? 0), + mode: mchoseModeName(payload[17] ?? 0), + }; +} + +/** Pack a link's rate/stage pair back into its byte: rate high, stage low. */ +export function mchosePackLinkByte(rateIndex: number, dpiIndex: number): number { + return (((rateIndex & 0x0f) << 4) | (dpiIndex & 0x0f)) & 0xff; +} + +/** + * Build the `0x57` write payload from a payload previously read with `0x67`. + * + * The write layout is exactly the read layout with a command byte in front, so + * a change is applied by echoing every other byte back untouched — which is + * what keeps button mappings, macros and DPI values from being wiped by a + * partial write. + */ +export function mchoseEncodeConfigWrite( + readPayload: Uint8Array, + changes: { + wiredRateIndex?: number; + wiredDpiIndex?: number; + wirelessRateIndex?: number; + wirelessDpiIndex?: number; + dpiStages?: readonly number[]; + keyDebounce?: number; + stageCount?: number; + } = {}, +): number[] { + const current = mchoseDecodeConfig(readPayload); + if (!current) throw new Error("mchoseEncodeConfigWrite needs a decodable 0x67 payload"); + const out = [MCHOSE_COMMAND.writeConfig, ...readPayload]; + // +1 throughout: the command byte shifts every field by one. + out[1 + 1] = mchosePackLinkByte( + changes.wiredRateIndex ?? current.wiredRateIndex, + changes.wiredDpiIndex ?? current.wiredDpiIndex, + ); + out[1 + 2] = mchosePackLinkByte( + changes.wirelessRateIndex ?? current.wirelessRateIndex, + changes.wirelessDpiIndex ?? current.wirelessDpiIndex, + ); + if (changes.dpiStages) { + for (let stage = 0; stage < MCHOSE_DPI_STAGES; stage += 1) { + const value = changes.dpiStages[stage] ?? current.dpiStages[stage] ?? 0; + out[1 + 4 + stage * 2] = value & 0xff; + out[1 + 4 + stage * 2 + 1] = (value >> 8) & 0xff; + } + } + if (changes.keyDebounce !== undefined) out[1 + 18] = changes.keyDebounce & 0xff; + if (changes.stageCount !== undefined) out[1 + 16] = changes.stageCount & 0xff; + return out; +} + +// ── model catalog ────────────────────────────────────────────────────────── + +/** + * PIDs shared by the whole A7 V2 family, so they identify a link rather than a + * model. The receiver is the endpoint the host talks to; the mouse reports its + * own model-specific id inside the battery reply. + */ +export const MCHOSE_LINK_PRODUCT_IDS = { + receiver: 0x100b, + bluetooth: 0x100a, + receiver8k: 0x1020, +} as const; + +export interface MchoseProduct { + name: string; + /** The mouse's own product id, model-specific. */ + productId: number; + dpiMax: number; + /** Lift-off steps in millimetres, in firmware index order. */ + liftOffDistances: readonly number[]; + /** Highest polling rate on a wired or 2.4 GHz link. */ + maxPollingRate: number; +} + +/** The A7 V2 family, from the M HUB bundle's own model table. */ +export const MCHOSE_PRODUCTS: readonly MchoseProduct[] = [ + { name: "A7 V2 Pro", productId: 0x4018, dpiMax: 26000, liftOffDistances: [1, 2], maxPollingRate: 8000 }, + { name: "A7 V2 Pro+", productId: 0x4023, dpiMax: 26000, liftOffDistances: [1, 2], maxPollingRate: 8000 }, + { name: "A7 V2 Ultra", productId: 0x4019, dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, + { name: "A7 V2 Ultra+", productId: 0x4021, dpiMax: 42000, liftOffDistances: [0.7, 1, 2], maxPollingRate: 8000 }, +]; + +/** Rate lists the firmware exposes, keyed by the model's maximum rate. */ +export const MCHOSE_POLLING_RATES: Readonly> = { + 1000: [125, 500, 1000], + 4000: [125, 500, 1000, 2000, 4000], + 8000: [125, 500, 1000, 2000, 4000, 8000], +}; + +/** + * Identify the model. The id inside the battery reply is decisive; the host- + * facing PID is shared across the family, so the product string is the only + * fallback, and an unrecognised one yields null rather than a wrong DPI ceiling. + */ +export function mchoseFindProduct( + mouseProductId: number | null, + productName?: string | null, +): MchoseProduct | null { + const byId = MCHOSE_PRODUCTS.find((product) => product.productId === mouseProductId); + if (byId) return byId; + const name = productName?.trim().toUpperCase() ?? ""; + if (!name) return null; + // Longest name first so "A7 V2 Ultra+" is not swallowed by "A7 V2 Ultra". + return [...MCHOSE_PRODUCTS] + .sort((a, b) => b.name.length - a.name.length) + .find((product) => name.includes(product.name.toUpperCase())) ?? null; +} + +/** Polling rates available on the link this host-facing product id represents. */ +export function mchosePollingRates( + product: MchoseProduct, + hostProductId: number, +): readonly number[] { + const max = hostProductId === MCHOSE_LINK_PRODUCT_IDS.bluetooth ? 1000 : product.maxPollingRate; + return MCHOSE_POLLING_RATES[max] ?? MCHOSE_POLLING_RATES[1000]!; +} + +export * from "./buttons.ts"; +export * from "./dock.ts"; + +/** + * Profile names, read one at a time with `0x12 0x68 `. The reply is the + * profile index followed by a NUL-terminated ASCII name — the test hardware + * answered "Config 1", "Config 2", "Config 3". + */ +export function mchoseEncodeReadProfileName(index: number): number[] { + if (!Number.isInteger(index) || index < 0 || index >= MCHOSE_PROFILE_COUNT) { + throw new Error(`MCHOSE profile index must be 0-${MCHOSE_PROFILE_COUNT - 1}.`); + } + return [MCHOSE_COMMAND.readProfileName, index]; +} + +export function mchoseDecodeProfileName(payload: Uint8Array): string | null { + if (payload.length < 2) return null; + let end = 1; + while (end < payload.length && payload[end] !== 0) end += 1; + const name = String.fromCharCode(...payload.subarray(1, end)).trim(); + return name.length ? name : null; +} diff --git a/tsconfig.json b/tsconfig.json index 739db85..2ad0eab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "@openmouse/protocol/keychron": ["./src/keychron/index.ts"], "@openmouse/protocol/lamzu": ["./src/lamzu/index.ts"], "@openmouse/protocol/logitech": ["./src/logitech/index.ts"], + "@openmouse/protocol/mchose": ["./src/mchose/index.ts"], "@openmouse/protocol/moddo": ["./src/moddo/index.ts"], "@openmouse/protocol/orbital": ["./src/orbital/index.ts"], "@openmouse/protocol/pulsar": ["./src/pulsar/index.ts"],