Open configuration for the EasySMX X20 gamepad, and for other controllers speaking the same KeyLinker protocol.
Verified against two X20 controllers. Up to four can be configured at once, one per player slot, each with its own save files.
It never touches firmware. Anything it changes is undone by a factory reset, from the app or by holding
Cfor five seconds. See Safety.If you have an X20 and something doesn't work, tell me the error or what the pad did. Start with IF-YOUR-CONTROLLER-ISNT-WORKING.md.
The X20 ships with no desktop configuration software. The vendor provides a manual, a driver, and a firmware updater. Everything else lives in a mobile-only app or behind button combinations on the pad itself. This project documents the protocol and makes it usable from a PC.
Working today: button remapping, macros on all four rear buttons with a piano-roll editor and recording, saved whole-controller setups, vibration strength, the idle shutdown timer, stick and trigger deadzones and response curves, battery level, a live input tester and a polling-rate meter, and factory reset. Up to four controllers at once. Driven from a desktop app or the command line. See status for exactly what is and isn't proven, and CHANGELOG.md for what landed in 1.0.0.
The protocol was reverse engineered from scratch. No prior documentation of
KeyLinker, Xpert2, or com.pulsenet.inputset appears to exist publicly.
Up to four controllers, one per player. Green while a controller is answering, red when it stops, and each one keeps its own save files.
The macro editor. One column is one step the controller stores, so a macro read back off the pad appears here unchanged. Cells hold buttons; stick rows open a dial that snaps to the eight directions the hardware can store.
Remapping, laid out the way the controller is: triggers, bumpers and sticks paired left against right, the d-pad against the face buttons.
Trigger travel and response, with a live meter. Each choice explains itself, and a controller holding something that matches no preset says so rather than pretending.
Deadzones and response curves, one editor per channel. The dotted diagonal is a linear response, and both control points are dragged directly.
The input tester: every button, both sticks with a position trail, the analog triggers, and a meter counting the reports per second that actually reach Windows.
The controller has two entirely separate command channels:
| Channel | Mechanism | Risk | Policy |
|---|---|---|---|
| Bootloader | USB mass storage, SCSI pass-through | can brick the device | never touched |
| Configuration | BLE GATT | recoverable | the only target |
- No SCSI, ever. No
\\.\PHYSICALDRIVE, no drive letters, no running the vendor updater. The bootloader is permanently out of scope. - Never enter upgrade mode (
L3held while connecting USB). - Read before writing. Every write is traceable to a captured read or to the vendor app's own code. Nothing is guessed.
- Tools refuse unsafe opcodes by default.
ble_query.pyandble_probe.pyaccept only read-only opcodes.verify_write.pycan construct exactly one packet, a provable no-op.set_vibration.pyalways reads first and reuses undecoded bytes verbatim instead of inventing values.
Recovery from any settings-level mistake: hold C for 5 seconds for a
factory reset. Settings live separately from firmware.
This project won't flash firmware, and that's deliberate instead of
unfinished. Flashing runs through the mass-storage bootloader, which is the one
path that can destroy the controller. Use the manufacturer's own updater for
that. x20 status reports the installed firmware version so you can tell when
you're behind.
| Feature | State |
|---|---|
| BLE transport, framing, CRC, scrambling | confirmed against hardware |
| Reading device info, capabilities, every setting | working |
| Macros on M1-M4 | working, verified by behaviour |
| ... sequences, chords, stick directions | working |
| ... multi-packet macros via chunked writes | working |
| Vibration strength | working, verified by feel at 0%, 30%, 100% |
| Battery level | working, four-step gauge plus charging flag, re-read on a timer rather than once at connection |
| Save files | working |
| Recording from live input | working |
| Input tester and polling meter | working |
| Stick deadzones and curves | working, verified by writing a deadzone and reading it back changed, then restoring it |
| Trigger deadzones and curves | working, same record layout, verified the same way |
| RGB lighting | not exposed by the X20 |
| Turbo | not exposed by the X20 |
| Gyro | not exposed by the X20 |
| Firmware update | deliberately out of scope, see Safety |
The pad reports its own capabilities in a descriptor that the official app uses to decide which settings pages to show. An X20 reports zero for lighting, turbo and gyro. Those features exist in the hardware but are driven entirely by on-pad button combinations, and aren't reachable through this protocol on this model.
This is a property of the controller, not a limitation of this software. Another brand's pad on the same chip may well report them as available, and this library gates on that descriptor, so it will simply work.
The three don't fail in the same way, which is worth knowing before anyone spends
an evening on them. Gyro is refused at the device: READ_3D is silent across
every payload shape tried. Lighting is not — HOST_LIGHTING answers with a
well-formed four-zone record, so the firmware handler is alive and only the menu
byte says otherwise. That record turns out to be a stored palette that never
changes when the LEDs do, and at four zones it is too long to write in one packet
anyway: 30 bytes against a 20-byte cap, with no chunked lighting write in
existence. Neither USB mode offers a way round it — DInput and Switch both report
feature=0, no vendor collection at all.
So lighting is closed, but for three specific reasons rather than one blanket one.
docs/01-protocol.md sections 4b and 4g have the evidence.
Windows, and Python 3.10 or newer. The Windows requirement is real rather
than incidental: the input tester and the polling meter read the pad through
XInput1_4.dll directly, save files go to %APPDATA%, and the taskbar icon is
set through a Windows shell call. It won't run elsewhere as it stands.
Two parts of it aren't Windows-bound, if you want to port it. The protocol library is pure computation with no I/O at all, and the BLE transport goes through bleak, which supports Linux and macOS. What would need writing is a replacement for the XInput reader, since that's where the platform is baked in.
Pair the controller over Bluetooth. Settings travel over a Bluetooth LE link that the pad exposes separately from however you play, so Bluetooth is needed to change anything even when you are on a cable or the 2.4GHz receiver. Playing is unaffected, and the input tester works without it.
pip install -e ".[gui]"That puts two commands on your PATH, so neither needs you to be in this directory:
| Command | What it does |
|---|---|
x20ctl |
opens the desktop app |
x20 |
the command line interface |
Without installing, run them as python -m x20ctl.gui and python -m x20ctl
from the project directory.
x20ctlSave files down the left, the four macro slots and vibration on the right. The header shows battery, and which link the pad is being played over.
Editing a slot validates as you type, and a macro set to loop is marked, since that's the one setting that can surprise you. Every section has an info button explaining what its controls do and why.
Edits save themselves, and the Save button shows whether anything is pending, so you can press it when you want to be sure. The app only interrupts when you clear a slot that held a macro, and then it offers to put it back, because a macro already written to the controller can't be read off it again.
Record on any slot captures what you press on the controller, with the real timing between presses, and fills the slot in.
Sticks and triggers is a page of its own, with a deadzone pair and a draggable response curve for each of the four channels. Nothing is written until you press Write, and what the controller reports afterwards is what the page then shows. See sticks and triggers.
Input tester shows every button lighting as it's held, both sticks with a position trail, and the triggers' analog values, alongside a meter counting how many reports per second actually reach Windows. The trail is what makes stick behaviour visible: rolling around the edge should trace a clean circle, and a released stick should settle dead centre.
Applying makes the controller match the save file exactly, so switching between save files really switches. Any slot the file leaves empty is cleared on the pad. Settings the controller doesn't expose are skipped rather than attempted, and the app says which.
Four channels, each with two deadzones and a response curve.
The inner deadzone is the slack around the centre the controller ignores, which is what to raise if a released stick drifts and what to lower for finer aim. The outer deadzone is where travel starts counting as fully pressed. An X20 ships with 8 and 92 out of 100 on both sticks.
The curve is two control points. On the diagonal the response is linear:
output matches movement. Above it the controller moves faster than your thumb,
below it more gently. An X20's sticks are linear out of the box and its triggers
are not, sitting at (82,133) and (229,235), ramping faster than linear.
Drag either point in the app, or set them exactly from the command line:
x20 curve show both records
x20 curve sticks --inner 4 widen the usable travel
x20 curve triggers --linear straighten the response
x20 curve sticks --side left --invert-yTwo things are worth being straight about.
Writing is confirmed on both records. A left-stick deadzone was written from
8 to 10 and a left-trigger deadzone from 4 to 6, each read back changed with the
right channel untouched, and each restored byte for byte. Every write reads the
record back and tells you whether the controller reports what it was sent,
saying the controller kept its own values rather than claiming success if it
doesn't. Anything changed can be put back: the app keeps the values read at
connection, and the command line prints a --restore line before each write.
The line drawn between the control points is this app's own. The points are read and written exactly, but nothing documents how the firmware interpolates between them, so the curve is drawn as a smooth monotone interpolation through them. It's a faithful picture of the points, not a claim about the hardware's arithmetic.
A save file is a set of up to four macros and a vibration level, stored as JSON
in %APPDATA%\x20ctl\profiles. The controller has four fixed macro slots and
knows nothing about save files; switching files rewrites those slots.
Save files belong to you, not to the app. They live in your own profile
directory, nowhere near the executable, so replacing x20ctl.exe with a newer
download leaves every save file exactly where it was. That matters more than it
sounds for a single-file build: anything stored beside the executable would
land in a temporary folder that's deleted the moment the app closes.
{
"name": "Save file 1",
"vibration": 30,
"macros": {
"M1": { "keys": "A+B", "hold_ms": 100, "gap_ms": 60, "loop_ms": 0 },
"M2": { "keys": "X,Y", "hold_ms": 120, "gap_ms": 80, "loop_ms": 0 },
"M3": null,
"M4": null
}
}In a key sequence, + presses keys together and , plays them one after
another, so A+B is a chord and A,B is a sequence. Sticks are written as a
direction, LS_UP or RS_DOWN_LEFT, and compose with the rest: LS_UP+A pushes
the left stick up while holding A.
Fourteen buttons can appear in a macro: A, B, X, Y, LB, RB, LT, RT, L3, R3 and the four d-pad directions, plus both sticks. Select, Start and Home can't: the pad's own macro key list omits them.
Each step can carry its own timing, which is what the hardware stores:
A:150holds A for 150 msA:150/40holds 150 ms, then waits 40 ms before the next stepA:150, Btimes each step independently
Steps without their own timing fall back to the three defaults:
hold_ms: how long each press lasts. 50 ms is faster than a human can press and some games poll slowly enough to miss it; 80 to 120 ms is more reliable.gap_ms: the pause between presses, which games that debounce input need.loop_ms: an interval, not a duration.0fires the macro once; anything else repeats it forever until another macro button is pressed. The hardware has no "repeat for N seconds" setting.
All three snap to multiples of 5 ms, which is the controller's own resolution.
Worth knowing before planning anything elaborate:
- Sticks store eight directions, always at full deflection. A macro can say "up" or "up-right", but nothing between them, and it can't say "half right". Anything that needs a specific stick angle won't work.
- 25 steps maximum. The record states its own length in one byte.
- Timing is fine. 5 ms resolution, and a button repeated across consecutive steps reads as one continuous hold.
There is a partial way around the magnitude limit. Flicking a direction on and off every 5 ms gives a proportional input if the receiving program averages over time. A 50% duty cycle steers about half as far as holding it. This does nothing if the program reads the stick at a single instant instead. Both cases measured in docs/01-protocol.md.
x20 scan
x20 status
x20 vibration 60
x20 curve sticks --inner 4
x20 macro M1 "A+B" --hold 100
x20 profile set "Save file 1" M1 "A+B" --vibration 30
x20 profile apply "Save file 1"The address is remembered after the first successful scan, so most commands need
no arguments. x20 --version reports this tool's version; x20 status reports
it alongside the controller's firmware, which is a different number.
Find the controller. It advertises as Xpert2, on a MAC distinct from the one it
uses for the gamepad interface:
python tools/ble_scan.pyInspect its GATT table:
python tools/ble_enum.py <address>Read a setting:
python tools/ble_probe.py <address> HOST_MOTOR --label baselineRead every setting at once:
python tools/ble_sweep.py <address> --opcodes HOST_STICK,HOST_TRIGGER,HOST_MOTORRead and change vibration strength:
python tools/set_vibration.py <address>
python tools/set_vibration.py <address> --percent 60
python tools/set_vibration.py <address> --restore 4cProve the pad accepts a curve write, and put it straight back. This is how the results in status were obtained, and it's worth running on any controller that isn't an X20:
python tools/verify_curve_write.py sticks --read-only
python tools/verify_curve_write.py triggersTurn a stick movement in a gamepad-overlay video into a macro. Needs
opencv-python, and --debug writes annotated frames so you can check the
tracking before trusting the result:
python tools/macro_from_video.py clip.mp4 --hue 5 25 --debugIt can't be one to one, and that's the hardware: a macro step stores a stick as one of eight compass directions, so how far it was pushed isn't recorded and can't be replayed. A macro also holds about 25 steps, since the record states its own length in a single byte, so the tool merges runs and keeps the longest.
Collect a compatibility report from a controller, to see whether this works on something other than an X20. It only reads, and it writes the result to a text file you can look over before sharing it:
python tools/report.pyRun the tests, no hardware required:
python tests/test_protocol.pyFull detail in docs/01-protocol.md.
Transport. BLE GATT. Service d7f010e0-660d-46e9-96c3-19c4148bdab5, write on
...e1, notify on ...e2. The pad advertises this as a separate peripheral
alongside its gamepad interface, so both are live at once.
Packets. [opcode][length][serial][nonce][payload][crc8], capped at 20 bytes
to fit a default BLE MTU, then passed through a scrambling pass. Replies use a
single RESPONSE opcode and are matched to requests by the serial byte.
Checksum. Reflected CRC-8, polynomial 0xEB. The table is generated from the
polynomial and asserted equal to the one shipped in the vendor app on every test
run.
Payloads. Byte 0 of every response is a length prefix. Records longer than one packet are chunked and fetched by index.
docs/ protocol documentation and the reverse engineering log
tools/ discovery and configuration utilities
x20ctl/ the protocol library
tests/ offline tests, including bytes captured from real hardware
vendor/ manufacturer binaries, analysis only, never committed
captures/ packet logs, never committed
The protocol belongs to the chip vendor (ShenZhen ZhiXu, package
com.pulsenet.inputset), not to EasySMX, so it likely covers controllers from
several brands. Two things to know before pointing this at other hardware:
- Don't identify a pad by USB VID/PID. The X20 clones Microsoft's
045E:028Ewhen wired and045E:02FDover Bluetooth Classic. Matching on those would target genuine Xbox controllers. Identify by the BLE peripheral instead. - Always read the capability descriptor first and honour it. It's how the pad tells you which settings it will accept.
Checked and found unsupported: the EasySMX X05 (investigated by @SpookyyQ). It's a different chip and firmware, not another KeyLinker device. RGB, macros (M1/M2) and vibration are all set with on-pad button combinations from the manual; there's no companion app, no BLE service advertised, and no HID feature report on either its USB or Bluetooth identity for this library to speak to. See docs/00-findings.md for what was checked. The input tester and connection detection, which are generic XInput/PnP code rather than KeyLinker-specific, aren't affected by this.
x20ctl.exe is a PyInstaller one-file build of the source here, made by
tools/build_exe.py. Three ways to check it without taking
my word for anything:
1. Check you got what was published. Every release lists a SHA-256.
Get-FileHash .\x20ctl.exe -Algorithm SHA256| Release | SHA-256 |
|---|---|
| 1.2.0 | 89cc96848b633756b37c9f1eb876b645624ed7833e31af104e0ecd519480a519 |
| 1.1.2 | 4ef82cebf50fb329e984d2e07c4664aecc785399496f841131d4352e07430a48 |
| 1.1.1 | cbf39473f2ea6c9cec648c4c785d30a0f58c7e14fc8fb7b75fb23b47d68743ad |
| 1.1.0 | b7c2b860a3472a454c5cb1e373d88da4f7760f3c418aa190d810ec7d3e8af41b |
| 1.0.1 | 1da4779ee7b636a94217fa1a3e90ae4fdc2f35a68b5b0fa102fb114b6deb6c2a |
| 0.2.1 | 0593a3251b19f8d2cd0376456dcb691adaa7eac8c9f7d23edf6a472cb7913cb4 |
| 0.2.0 | d5f3fb1842df7089cdaa3b6260714770ef85815f7ac0cad7a725aa3b82d8efb6 |
| 0.1.0 | 4923b44f5ec3bbb61f93a831d7f7a9c0114228e62d5fb9fc4e30e11bad2d74b8 |
2. Scan it yourself. Upload it to VirusTotal and read which engines object and what they claim to have found.
3. Don't use the binary at all. pip install -e ".[gui]" runs the same
application from source, which you can read.
About antivirus warnings. A one-file PyInstaller build carries a Python interpreter inside it and unpacks itself to a temp folder on startup. Several engines flag that shape on its own, and this build is unsigned because I don't have a code signing certificate. A generic heuristic detection is common here and doesn't mean much. A detection naming specific behaviour would, so tell me if you see one. See SECURITY.md.
The program talks BLE to the controller's configuration service, reads and writes gamepad settings, and reads XInput for the input tester. There is no network code in it and no path to the controller's bootloader. See Safety.
- @SpookyyQ checked a physical EasySMX X05 over Bluetooth and documented that it doesn't speak the KeyLinker protocol — see docs/00-findings.md.
MIT, see LICENSE.
This is an independent interoperability project, not affiliated with or endorsed by any manufacturer. No vendor firmware, application binaries, or decompiled source are distributed here.





