Skip to content

feat: polling, DPI colours, X/Y axes, keyboard combos, buttons UI, packaging and CI - #2

Merged
mateusands merged 5 commits into
mainfrom
feat/protocol-completo
Aug 8, 2026
Merged

mateusands merged 5 commits into
mainfrom
feat/protocol-completo

Conversation

@mateusands

Copy link
Copy Markdown
Owner

Brings the driver to parity with the official Windows app except macros, and
corrects three things we had wrong.

Protocol

Feature Register Evidence
USB polling rate 0x0000 all four codes written, read back, restored; report rate measured ~1002 Hz at 0x01
Per-stage DPI colours 0x002C byte-match against our capture; values match the app's Cfg.ini DC= line
Independent X/Y axes 0x000C written and read back on hardware
Keyboard keys and combos extended slot byte-identical to the records the official app wrote

The extended slot holds an event list

<event count> [ <type> <code lo> <code hi> ] * count <checksum>

04 | 80 01 00 | 81 06 00 | 40 01 00 | 41 06 00 | c1
     ctrl down   c down     ctrl up    c up

Assigning Ctrl+C through the official app produced a four-event record, which is
what showed the first byte counts events rather than naming a type. The codes are
standard HID usages: 0x01 is the left-control bit, 0x06 the keyboard usage
for "c".

Three corrections

0x07/0x08 were swapped. 0x08 is RGB ON/OFF, 0x07 is the polling
switch — as m913-ctl always said. Our earlier reading came from a run that
changed seven buttons at once; a single-variable test settled it.

The button-to-index map was wrong. Buttons 6, 7 and 8 are indices 10, 11 and
9 — not 9, 10, 11. Every row is now proven by its own experiment.

build_write produced oversized packets. With more than ten data bytes the
padding count went negative and the packet came out 21 bytes long, past the
17-byte HID report. build_combo hit exactly that. It now raises, and
build_events splits a long record across packets the way the app does. The
previous byte-match missed it by comparing only the data slice, never the packet
length; there is a test for that now.

Interface

A Buttons page for the 16 slots. Unknown action bytes render as
unknown (09 00 00) and cannot be selected, so the UI never rewrites a record it
cannot interpret. The DPI page's polling section was decorative and claimed the
rate was unmapped; it is a real control now, built from protocol.POLLING_RATES
so it cannot drift. Stage colour swatches and an X/Y toggle.

The tkinter fallback was advertising effects as "coming soon" that the core has
had for a long time, and drawing dead panels. Fixed and made honest about its
scope.

Tests and packaging

43 tests to 112. The device and CLI layers were untested; they now have 67
tests with the hidraw layer replaced by tripwires that raise if anything reaches
the mouse. The one that matters most proves a DPI stage holding 0xC8 — above
the 0xBD ceiling, so its round-trip is lossy — survives a change to a
different stage byte for byte.

CI on 3.8–3.13, running the suite once with nothing installed to prove the core
is stdlib-only. pip install-able with the GUI behind an extra, a udev rule
using uaccess rather than 0666, and a desktop entry.

How to test

python3 -m pytest
python3 main.py polling            # and: polling 500
python3 main.py buttons
python3 main.py button 7 combo:ctrl+c
python3 main.py                    # GUI: Lighting / Sensitivity / Buttons

Not included

Macros — the only remaining feature, and it needs a real USB capture. The 0x09
action type at index 6 stays unknown: no button maps to that slot, so the
official app cannot be used to learn it. The 0x0054 LED block accepts writes
but produces no visible change on this model; documented as a dead end.

🤖 Generated with Claude Code

mateusands and others added 5 commits August 8, 2026 12:48
…ard combos

USB polling rate (register 0x0000, 2-byte record). The codes were first decoded
by m913-ctl from their captures; they are now verified here — all four written,
read back and restored, and with the register at 0x01 the mouse's interrupt
endpoint measured ~1002 Hz, which pins the meaning independently.

Per-stage DPI colours (0x002C): five 4-byte records in the same three captured
shapes as the DPI table. The values read off this mouse match the DC= line of
the official app's Cfg.ini exactly.

Independent X/Y axes: a DPI entry is `X Y 00 ck`, two separate axis bytes the
official app keeps equal. Entries now accept a single value or an (x, y) pair;
existing callers produce byte-identical packets, which the untouched byte-match
tests prove.

The extended slot holds an EVENT LIST, not a fixed pair:

    <event count> [ <type> <code lo> <code hi> ] * count <checksum>

Decoded here by assigning Ctrl+C through the official app: the record has four
events (modifier down, key down, modifier up, key up), which is what showed the
first byte counts events rather than naming a type. The codes are standard HID
usages — 0x01 is the left-control bit and 0x06 the keyboard usage for "c".
build_key is confirmed by the same experiment, so its "unverified" warning is
gone; build_combo is new.

Two corrections this decoding forced:

- 0x08 is RGB ON/OFF and 0x07 is the polling switch, not the other way round.
  A single-variable test (button 2 set to RGB ON/OFF and nothing else) settled
  it. The earlier reading came from a run that changed seven buttons at once,
  whose button-to-index attribution turned out to be wrong.
- build_write silently produced packets longer than the 17-byte HID report when
  the data exceeded ten bytes; the padding count went negative. It now raises,
  and build_events splits a long record across packets the way the app does.
  A test now asserts the packet length, which is what the previous byte-match
  missed by only comparing the data slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third page exposes the 16 button slots. Unknown action bytes are shown as
`unknown (09 00 00)` and cannot be selected, so the UI never rewrites a record
it cannot interpret — send_button preserves the other fifteen byte for byte and
the page must not break that.

The DPI page's polling section was decorative and claimed the rate was "not
mapped yet". It is now a real control built from protocol.POLLING_RATES, so the
UI cannot drift from the protocol, and it distinguishes a silent device from an
undecodable code instead of collapsing both into one message.

Each DPI stage gains a colour swatch, and an "Independent X / Y axes" toggle
reveals a per-axis field. Toggling writes nothing on its own; the rule takes
effect on the next edit.

The tkinter fallback was advertising "Cycle (coming soon)" and "Breathing
(coming soon)" for effects the core has had for a long time, and drawing dead
DPI and polling panels. The three colour effects now work, and the DPI page
says plainly that this fallback covers lighting only, pointing at the Qt
interface and the command line for the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67 new tests. The hidraw layer is replaced by fakes, and _send_feature and the
device open are swapped for tripwires that raise, so a test that slipped through
fails loudly instead of reaching the mouse.

The most valuable one guards the project's central promise: the fixture holds
0xC8 in a DPI stage — above the 0xBD firmware ceiling, so byte -> DPI -> byte is
lossy there — then changes a different stage and asserts 0xC8 comes back out of
the wire packets untouched. The stage bytes are decoded from the captured
packets rather than compared against the builder, so it is an independent check.

CI runs the suite on 3.8 through 3.13. The first step runs it with nothing
installed, which is the actual proof that the core is stdlib-only; a second job
installs the package and checks the entry points resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PEP 621 metadata with the core installing dependency-free and PySide6 behind a
`gui` extra. Two entry points: `open-m711pro` for the CLI and `open-m711pro-gui`
for the interface. m711pro/__main__.py makes `python -m m711pro` work and holds
the dispatch, which main.py at the root now delegates to instead of duplicating.

The udev rule uses TAG+="uaccess" rather than MODE="0666". A hidraw node is not
inert: reading it sees every button and movement, and writing it reprograms the
device — this project maps buttons to arbitrary keystrokes. 0666 grants both to
every account permanently; uaccess has logind hand access to the active local
session and revoke it on logout. A GROUP fallback ships commented out for
systems without logind.

install.sh is idempotent, refuses to run as non-root before touching /etc, and
has an --uninstall flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Register 0x0000 was documented as the active DPI stage; it is the USB polling
rate, now verified here. The extended slot section is rewritten around the event
list model, with the Ctrl+C record annotated event by event.

Adds the button-to-index map. The table has 16 slots and this mouse uses eight
of them out of order: buttons 1-5 are indices 0-4, but 6, 7 and 8 are indices
10, 11 and 9. Every row was proven by a single-variable experiment; an earlier
version of this map, deduced by correlating stage colours with a description,
had two of the three wrong.

Index 6 holds 09 00 00 from the factory and no button maps to it, so the
official app can never be used to learn what it means — recorded so nobody
retries it.

The 0x0054 block mirrors the M913's main LED register and this mouse holds
values byte-identical to their rainbow template. Writes are accepted and read
back, but no visible change was ever observed — not a colour change, not
switching mode from rainbow to steady, not full brightness. Documented as a
dead end so the next person does not spend an afternoon on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mateusands
mateusands merged commit d3801f0 into main Aug 8, 2026
14 checks passed
Sign up for free to 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