Skip to content

Repository files navigation

x20ctl

Open configuration for the EasySMX X20 gamepad, and for other controllers speaking the same KeyLinker protocol.

1.0.0

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 C for 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.

The controller roster

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.

Macros

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.

Button remapping

Remapping, laid out the way the controller is: triggers, bumpers and sticks paired left against right, the d-pad against the face buttons.

Triggers

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.

Sticks and triggers

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

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.


Safety

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

Rules this project follows

  1. No SCSI, ever. No \\.\PHYSICALDRIVE, no drive letters, no running the vendor updater. The bootloader is permanently out of scope.
  2. Never enter upgrade mode (L3 held while connecting USB).
  3. Read before writing. Every write is traceable to a captured read or to the vendor app's own code. Nothing is guessed.
  4. Tools refuse unsafe opcodes by default. ble_query.py and ble_probe.py accept only read-only opcodes. verify_write.py can construct exactly one packet, a provable no-op. set_vibration.py always 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.

On firmware updates

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.


Status

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

About the three "not exposed" rows

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.


Install

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.


The desktop app

x20ctl

Save 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.


Sticks and triggers

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-y

Two 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.


Save files

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:150 holds A for 150 ms
  • A:150/40 holds 150 ms, then waits 40 ms before the next step
  • A:150, B times 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. 0 fires 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.

What a macro can't do

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.


Command line

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.


Low-level tools

Find the controller. It advertises as Xpert2, on a MAC distinct from the one it uses for the gamepad interface:

python tools/ble_scan.py

Inspect its GATT table:

python tools/ble_enum.py <address>

Read a setting:

python tools/ble_probe.py <address> HOST_MOTOR --label baseline

Read every setting at once:

python tools/ble_sweep.py <address> --opcodes HOST_STICK,HOST_TRIGGER,HOST_MOTOR

Read 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 4c

Prove 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 triggers

Turn 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 --debug

It 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.py

Run the tests, no hardware required:

python tests/test_protocol.py

The protocol, in brief

Full 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.


Layout

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

Notes for other devices

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:028E when wired and 045E:02FD over 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.


Is the executable safe

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.

Acknowledgements

  • @SpookyyQ checked a physical EasySMX X05 over Bluetooth and documented that it doesn't speak the KeyLinker protocol — see docs/00-findings.md.

Licence

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.

About

Open configuration library for the EasySMX X20 gamepad and other KeyLinker-protocol controllers. Reverse engineered for interoperability.

Resources

Security policy

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages