Skip to content

Support the Lingbao M5 Pro (PAW3395), and stop the Fantech driver inventing readings - #49

Closed
phunb3101 wants to merge 1 commit into
OpenMouse-Project:mainfrom
phunb3101:feat/lingbao-m5-pro
Closed

Support the Lingbao M5 Pro (PAW3395), and stop the Fantech driver inventing readings#49
phunb3101 wants to merge 1 commit into
OpenMouse-Project:mainfrom
phunb3101:feat/lingbao-m5-pro

Conversation

@phunb3101

Copy link
Copy Markdown

Adds support for the Lingbao M5 Pro (PixArt PAW3395), and fixes the
fantech driver, which this work showed was reporting invented values.

Why the M5 Pro did not work

It enumerates under VID 0x3151 on the very same vendor interface
FantechHidClient claims — usage page 0xFFFF, usage 0x02, one unnumbered
64-byte feature report — so driverFor() handed it to that driver. Every read
came back as 64 zero bytes, and the driver decoded those zeros into a
plausible-looking mouse instead of failing.

0x3151 is not Fantech's vendor id. It belongs to MicLink/mlzn, an ODM
whose devices are configured by GearHub-V5. The
protocol below was read out of GearHub's own web bundle and then verified byte
for byte against real hardware.

What the Fantech driver was missing

fantech hasThe device needs
Checksumnonebyte[7] = 255 - sum(bytes 0..6)
2.4G pathwrites straight to the receivera four-step relay
DPI commandGET_DPI 144 / SET_DPI 160xD4 / 0x54
Rate table6 entries, code 5 = 125 Hz (the keyboard table)7 entries, code 5 = 250 Hz

Omit any one of these and the device ACKs the SET_FEATURE and answers 64
zeros — silently, with no error. That is indistinguishable from hardware that
does not speak the protocol at all, which is why this went unnoticed.

The relay is the part that is easy to miss. A receiver does not forward a
command just because one was written to it:

1. F6 05 select the paired mouse as the target (0A = keyboard)
2. F7 poll receiver status until it reports ready
3. <command> the checksummed 9-byte command
4. FC "notice read", then read the feature report back

F7 also carries link state and battery, so battery comes free and is a real
reading rather than a capability guess.

The new driver

LingbaoHidClient, scoped to the M5 Pro's two product ids — 0x402D
(2.4G receiver, relayed) and 0x4026 (wired, direct) — so it cannot shadow
Fantech's own hardware. FantechHidClient now leaves those two ids alone, and
the registry's existing one-driver-per-device test enforces the split.

Commands are serialised through a queue: the relay is stateful, so two
exchanges in flight at once interleave and corrupt each other. readStatus()
ran into this on its first concurrent read, which is worth calling out because
it is invisible in any single-command test.

Reads: DPI stages + active stage + per-stage indicator colour, report rate,
battery, link state, firmware, device id. Writes: DPI (echoing back every
stage it is not changing) and report rate.

Verified live

Against a real M5 Pro receiver, driving the actual LingbaoHidClient through
a WebHID shim over node-hid:

brand : Lingbao
name : Lingbao M5 Pro (2.4G receiver)
battery : 44% (Discharging)
connection : Wireless (2.4 GHz)
DPI : 1600 x 1600
DPI stages : 400, 800, 1600, 3200, 6400, 26000
active stage : 2
polling rate : 8000 Hz
firmware : v3.03 · PixArt PAW3395
device id : 2285

An idle M5 Pro drops off its receiver within minutes; readStatus() reports
that as its own error ("no mouse is linked … move the mouse and try again")
rather than as a protocol failure, which the run above exercised before the
mouse woke.

Fantech fixes carried in the same change

The investigation showed FantechHidClient fabricating data on any device
that does not answer, so:

  • readStatus() now throws instead of returning 1600 DPI / 8000 Hz / Wired (USB) decoded from an all-zero reply, letting the caller try the next driver
  • setDpiForSlot() no longer zeroes every DPI slot it is not writing — it read
    the current table and then discarded it
  • slot bounds were > 8 where the layout holds 8 slots (indices 0-7), and the
    clamp was applied to the reported index but not to the byte offsets
  • 250 Hz is no longer advertised when it cannot be encoded; the UI renders one
    button per advertised rate, so that button threw on click
  • the hardcoded "Wired (USB)" and the placeholder ["Fantech mouse"]
    firmware string are gone rather than mislabelling wireless units

Not covered

Bluetooth. The M5 Pro's third mode enumerates on a different usage page
(0xFF35/0xFF66, usage 0x0202) and GearHub drives it through a separate
read path. Not implemented, because there was no way to verify it here.

A concern worth flagging: PID 0x503d — the Fantech WG14P Yari Pro, the
only Fantech model this repo lists — appears in GearHub's device table as
type:"dongle". If that is right, it needs the same relay handshake, and the
fantech driver is likely broken on its own hardware too. I did not change its
command set, having no Fantech device to verify against; guessing would only
trade one silent failure for another. After this change it at least fails
loudly instead of inventing numbers.

Testing

tsc --noEmit : clean
lingbao tests : 12/12
full suite : 844/844
npm run build : OK
live hardware : PASS (output above)

🤖 Generated with Claude Code

The Lingbao M5 Pro (PixArt PAW3395) enumerates under VID 0x3151 on the
very same 0xFFFF/0x02 vendor interface FantechHidClient claims, so the
registry handed it to that driver — which could not read a single value
from it, and reported fabricated ones rather than failing.
0x3151 belongs to MicLink/mlzn, the ODM, not to Fantech. The mouse is
configured by GearHub-V5 (qmk.top); its protocol was read out of that
bundle and then verified byte for byte against real hardware.
LingbaoHidClient implements the three things the Fantech driver lacks:
- The Bit7 checksum, byte[7] = 255 - sum(bytes 0..6). Without it the
device ACKs the write and answers 64 zero bytes, silently.
- The 2.4G relay. A receiver does not forward a command just because
one was written to it: select the target (0xF6 0x05), poll status
until ready (0xF7), send, then notice-read (0xFC) before reading
back. 0xF7 also carries link state and battery.
- The mouse-class command set (GET_DPI 0xD4 / SET_DPI 0x54) and its
seven-entry report-rate table, in which code 5 means 250 Hz. The
Fantech driver carries the six-entry keyboard table, where code 5
means 125 Hz instead.
Commands are serialised through a queue. The relay is stateful, so two
exchanges in flight at once interleave and corrupt each other —
readStatus() ran into this on its first concurrent read.
Scoped to the M5 Pro's two product ids (0x402D receiver, 0x4026 wired) so
it cannot shadow Fantech's own hardware, and FantechHidClient now leaves
those two ids alone; the registry's one-driver-per-device test enforces it.
Verified live on a Lingbao M5 Pro receiver: DPI stages 400/800/1600/3200/
6400/26000 with stage 2 active, 8000 Hz, battery 44%, firmware v3.03,
device id 2285. Bluetooth mode is not covered — over BLE the mouse moves
to a different usage page and a separate read path.
The same investigation showed FantechHidClient inventing data on any
device that does not answer, so this also fixes:
- readStatus() throws instead of returning 1600 DPI / 8000 Hz decoded
from an all-zero reply, letting the caller try the next driver
- setDpiForSlot() no longer zeroes every DPI slot it is not writing
- 250 Hz is no longer advertised when it cannot be encoded, so the UI
cannot render a button that throws on click
- the hardcoded "Wired (USB)" and the placeholder firmware string are
gone rather than mislabelling wireless units
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@phunb3101