Skip to content

Latest commit

 

History

History
224 lines (160 loc) · 7.68 KB

File metadata and controls

224 lines (160 loc) · 7.68 KB

The analog travel protocol

Reverse-engineered wire protocol for the Hall-effect keyboards built on the 0x0416:0x7372 controller. This document is deliberately implementation agnostic — everything here is bytes on the wire, so you can build against it in Python, Rust, C, Go or JavaScript without reading a line of KeyAxis.

Released under the same MIT licence as the rest of this repository. Use it freely. If it helps you add support to another project, that's the point — a link back is appreciated but not required.

If you get another board working, please open an issue with its VID/PID so the compatibility table in the README can grow.


1. Which keyboards is this?

Confirmed:

Board VID PID Notes
Redragon M68 / E-YOOSO HZ-68 0x0416 0x7372 Developed and verified against this
Redragon K712 RGB-M 0x0416 0x7372 Community-reported working (#3)

These cheap magnetic boards get rebadged constantly. The model name on the box means nothing — the USB IDs are the truth. Different brand, same IDs, almost certainly the same protocol.

Check your own board (Windows)

Device Manager → Keyboards → your board → Details → Hardware Ids. You want:

HID\VID_0416&PID_7372

Or programmatically, with Python + hidapi:

import hid
for d in hid.enumerate(0x0416, 0x7372):
    print(hex(d["usage_page"]), d["path"])

⚠️ A magnetic keyboard usually exposes several HID interfaces, and only one of them speaks this protocol. Do not just grab the first match.


2. Finding the right interface

Select the enumerated interface whose usage_page == 0xFF1B (a vendor-defined page). On Windows this is USB interface MI_02.

The other interfaces are the ordinary keyboard/consumer-control ones. Writing this protocol to those will silently do nothing.


3. Transport

Every host→device message is:

report_id (0x01)  +  63 bytes of payload   =  64 bytes total

Pad the payload with zeros to exactly 63 bytes. With hidapi that's:

h.write(bytes([0x01]) + payload.ljust(63, b"\x00"))   # returns 64 on success

Check the return value. A write that returns -1 means you're on the wrong interface — the single most common mistake, and it fails silently otherwise.


4. Commands

Heartbeat / status (optional, useful as a liveness check)

01 00 ...zeros...

Reply: 01 00 00 00 06 00 02 01 00 64 ...

ARM — start the travel stream

21 00 00 00 18 02 3E 26 3E 1E 1E 1E 3E 1E 1E 3E 1E 3E 2E 10 2E 30 3E

…then zero-padded to 63 payload bytes. Byte 5 (0x02) is the mode selector.

The trailing bytes from offset 6 onward are per-key actuation values captured from the vendor tool. Sending them verbatim, as above, works. They are written to volatile state, not flashed — this does not modify your keyboard.

DISARM — stop the travel stream

21 00 00 00 18 03

…zero-padded to 63. Same command, byte 5 = 0x03 instead of 0x02.

Always disarm on stop, disconnect and application exit. Leaving the stream armed leaves the board pushing reports at a host that has gone away.


5. The travel frame

Once armed, the device pushes input reports. Byte offsets differ depending on your HID layer, because some prepend the report ID and some don't:

Field hidapi (report ID included) WebHID / raw (no report ID)
report id d[0] = 0x01 (not present)
marker d[1] = 0x21 d[0] = 0x21
frame type d[5] d[4]
row d[7] d[6]
col d[8] d[7]
depth d[9] d[8]

Frame type: 0x03 = travel data, 0x01 = idle/ack (ignore it).

Depth is 0..40, in units of 0.1 mm → 0.0–4.0 mm of total travel. A real full press bottoms out around 39, not 40, so normalise against a calibrated per-key peak rather than the theoretical maximum if you want a true 100%.

row/col are the switch's position in the key matrix. That mapping is not the visual keyboard layout and differs per board size, so learn it by asking the user to press each key once and recording which (row, col) lights up.

The stream is event-driven

Frames arrive only when a key's depth changes — there is no fixed poll rate. Do not treat silence as "everything is zero"; treat it as "nothing changed". While a key is moving you'll see roughly 1 kHz of updates.

Open the device non-blocking and idle briefly when no report is pending, or you'll spin a core for nothing.


6. Minimal working reader

Complete and runnable — about twenty lines:

import hid, time

VID, PID, USAGE_PAGE = 0x0416, 0x7372, 0xFF1B
ARM    = bytes([0x21,0,0,0,0x18,0x02, 0x3E,0x26,0x3E,0x1E,0x1E,0x1E,0x3E,0x1E,
                0x1E,0x3E,0x1E,0x3E,0x2E,0x10,0x2E,0x30,0x3E])
DISARM = bytes([0x21,0,0,0,0x18,0x03])

def send(h, payload):
    return h.write(bytes([0x01]) + payload.ljust(63, b"\x00"))

path = next(d["path"] for d in hid.enumerate(VID, PID)
            if d.get("usage_page") == USAGE_PAGE)
h = hid.device(); h.open_path(path); h.set_nonblocking(1)

assert send(h, ARM) == 64, "wrong interface - write rejected"
try:
    while True:
        d = h.read(64)
        if not d:
            time.sleep(0.001)
            continue
        if d[0] == 0x01 and d[1] == 0x21 and d[5] == 0x03:
            row, col, depth = d[7], d[8], d[9]
            print(f"row={row:2} col={col:2} depth={depth:2} ({depth/10:.1f} mm)")
finally:
    send(h, DISARM)
    h.close()

7. Gotchas that cost real time

  • WebHID cannot do this. Chromium blocks HID reads on anything that enumerates as a keyboard (NotAllowedError), and the vendor interface exposes no output reports — only feature reports, which a page can't write to on a keyboard. Use a native HID layer. This is why KeyAxis has a Python backend rather than being a web app.
  • Verify which device you're attached to before reverse-engineering anything. Hours were lost here decoding a different keyboard that happened to be plugged into the same PC. Log whether your writes succeed, not merely that you made them.
  • The travel data is not exposed passively. A full sweep of every feature report ID returns only the keymap (0x09, 505 bytes) and a small config blob (0x0a). Nothing streams until you ARM.
  • The vendor's own configurator can wipe the board's keymap while you poke at it, leaving a keyboard that detects keypresses but types nothing. It looks bricked and isn't — the fix is to rewrite the keymap. Do not reflash firmware to "fix" it.
  • One handle at a time. If the vendor software (or another instance of your own tool) holds the interface, your open will fail.

8. How this was captured

The vendor configurator for these boards is a web app using WebHID, which made it unusually easy to wiretap: a script pasted into the page's console wrapped sendReport, sendFeatureReport and the inputreport event, logging every byte in both directions plus whether each call succeeded.

The one trick that unlocked it: the vendor tool's live "Travel Test" screen only arms when DevTools is undocked. Docked DevTools narrows the viewport, their layout code throws, and the arm command never fires — which is why several earlier capture attempts saw nothing but heartbeats.

If your board's configurator is a native app rather than a web one, a Wireshark USB capture (USBPcap) gets you to the same place: press a key slowly, find the reports whose bytes change monotonically as the key travels, and work backwards to whatever write preceded them.