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.
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.
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.
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.
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 successCheck 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.
01 00 ...zeros...
Reply: 01 00 00 00 06 00 02 01 00 64 ...
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.
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.
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.
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.
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()- 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.
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.