Wireless keyboard & mouse KVM switch (both IN and OUT) based on M5Stack CoreS3 SE and Atom S3U — capable of handling a 1000 Hz wireless mouse and Bluetooth keyboard.
Switch between multiple PCs (2+) with a single mouse button press. No cables for peripherals, no lag.
| Component | Qty |
|---|---|
| M5Stack CoreS3 SE IoT Controller (without Battery Bottom) | 1 |
| AtomS3U ESP32S3 Dev Kit with USB-A | 2 |
| M5GO Battery Bottom3 (for CoreS3 only) | 1 |
| M5GO / FIRE Battery Bottom Charging Base | 1 |
| M5Stack USB Module V1.2 — for M5Core (For legacy BT only) | 1 |
| DFRobot Bluetooth 4.0 USB Adapter (TEL0002) (For classic BT keyboard) | 1 |
| M3×25 DIN 912 A2 screws | 2 |
| M3×22 DIN 912 A2 screws | 2 |
| Device | Role |
|---|---|
| Apple Magic Keyboard (wireless, USB-C, 2021) | Bluetooth keyboard |
| Keychron M3 Mini (wireless USB-C dongle) | 1000 Hz wireless mouse |
This guide uses macOS. For other operating systems, download the equivalent files for your platform.
Download from arduino.cc/en/software.
Plug the first Atom S3U into your Mac, then install the USB driver from
👉 docs.m5stack.com/en/download
I used the .pkg installer — no reboot needed, just follow the prompts.
Open Arduino IDE → Tools → Ports.
Confirm /dev/cu.usbmodem… appears (if not — driver isn't installed).
Go to Settings → Additional boards manager URLs and paste:
https://m5stack.oss-cn-shenzhen.aliyuncs.com/resource/arduino/package_m5stack_index.json
Tools → Board → Boards Manager — search and install:
- M5Stack by M5Stack
- esp32 by Espressif Systems
Tools → Board → M5Stack → M5AtomS3
Tools → Manage Libraries — install:
M5UnifiedNimBLE-Arduino(by h2zero)FastLED(by Daniel Garcia)USB Host Shield Library 2.0(by Oleg Mazurov / Circuits At Home) — then apply pin modifications below
The M5Stack USB Module V1.2 has DIP switches on the back that select which GPIO pins are used for SS (chip select) and INT (interrupt). Set them as follows for CoreS3 SE:
| Switch | Position |
|---|---|
| SS G13 | OFF |
| SS G5 | ON |
| SS G0 | OFF |
| INT G35 | ON |
| INT G34 | OFF |
This selects SS2 (G5 → remapped to G1 in software) and INT1 (G35 → remapped to G10 in software).
The physical DIP switch labels refer to the original M5Stack Core pin mapping. On CoreS3 SE these module connector pins are routed to different GPIOs — the library pin modifications below handle the remapping.
After installing the library, modify these files in your Arduino libraries folder (typically ~/Documents/Arduino/libraries/USB_Host_Shield_Library_2.0/):
Quick way: Instead of editing files manually, copy all files from the
USB_Host_Shield_2.0_Replacements/folder in this repo and replace the corresponding files in your library folder.
Note: The upstream library (v1.7.0) already has an
ARDUINO_M5STACK_CORES3section inavrpins.h,usbhost.h, andUsbCore.h, but it uses INT=P14 which doesn't match our wiring (INT=GPIO10). Remove those#elif defined(ARDUINO_M5STACK_CORES3)blocks and apply the changes below to the generic#elif defined(ESP32)section instead.
Pin configuration (3 files):
-
avrpins.h— remove theARDUINO_M5STACK_CORES3block, then add afterMAKE_PIN(P17, 17);(in the generic ESP32 section):MAKE_PIN(P35, 35); MAKE_PIN(P36, 36); MAKE_PIN(P37, 37);
-
usbhost.h— remove theARDUINO_M5STACK_CORES3spi typedef, then change the generic ESP32spitypedef:typedef SPi< P36, P37, P35, P1 > spi; // SCK=36, MOSI=37, MISO=35, SS=1(CS)
Also add an ESP32 case in
SPi::init()soSPI.begin()uses the correct pins:#if defined(ESP32) USB_SPI.begin(36, 35, 37, -1); // SCK=36, MISO=35, MOSI=37, no HW SS #else USB_SPI.begin(); #endif
-
UsbCore.h— remove theARDUINO_M5STACK_CORES3typedef, then change the generic ESP32MAX3421Etypedef:typedef MAX3421e<P1, P10> MAX3421E; // SS=GPIO1(CS), INT=GPIO10
SSP (Secure Simple Pairing) for Apple keyboards (2 files):
-
BTD.h— add event defines (after the existingEV_defines, around line 110):#define EV_MODE_CHANGE 0x14 #define EV_USER_PASSKEY_REQUEST 0x34 #define EV_USER_PASSKEY_NOTIFICATION 0x3B
Add a passkey variable (after
char remote_name[30];, around line 492):/** SSP passkey for Passkey Entry (displayed to user, typed on keyboard). */ uint32_t sspPasskey;
-
BTD.cpp— three changes:a) In
hci_io_capability_request_reply()(around line 1401), change IO capability from0x03(NoInputNoOutput) to0x01(DisplayOnly) and auth requirement to0x04(MITM Required):hcibuf[9] = 0x01; // DisplayOnly (was 0x03) hcibuf[10] = 0x00; // OOB authentication data not present hcibuf[11] = 0x04; // MITM Required, Dedicated Bonding (was 0x02)
b) Add passkey event handlers in
ACL_event_task()— in the HCI event switch, after theEV_USER_CONFIRMATION_REQUESTcase (around line 714), add:case EV_USER_PASSKEY_REQUEST: #ifdef DEBUG_USB_HOST Notify(PSTR("\r\nUser Passkey Request"), 0x80); #endif { uint8_t buf[9]; buf[0] = 0x2F; buf[1] = 0x01 << 2; buf[2] = 0x06; for(uint8_t i = 0; i < 6; i++) buf[3 + i] = disc_bdaddr[i]; HCI_Command(buf, 9); } break; case EV_USER_PASSKEY_NOTIFICATION: sspPasskey = (uint32_t)hcibuf[3] | ((uint32_t)hcibuf[4] << 8) | ((uint32_t)hcibuf[5] << 16) | ((uint32_t)hcibuf[6] << 24); #ifdef DEBUG_USB_HOST Notify(PSTR("\r\n>>> PASSKEY NOTIFICATION: "), 0x80); USB_HOST_SERIAL.print(sspPasskey); #endif break;
c) In
HCI_DISCONNECT_STATE(around line 1093), add service reset and L2CAP flag cleanup so BTHID properly detects physical disconnects. Replace the entire case body with:case HCI_DISCONNECT_STATE: if(hci_check_flag(HCI_FLAG_DISCONNECT_COMPLETE)) { #ifdef DEBUG_USB_HOST Notify(PSTR("\r\nHCI Disconnected from Device"), 0x80); #endif // Reset all services (BTHID etc.) so connected flags become false for(uint8_t i = 0; i < BTD_NUM_SERVICES; i++) if(btService[i]) btService[i]->Reset(); hci_event_flag = 0; memset(hcibuf, 0, BULK_MAXPKTSIZE); memset(l2capinbuf, 0, BULK_MAXPKTSIZE); connectToWii = incomingWii = pairWithWii = false; connectToHIDDevice = incomingHIDDevice = checkRemoteName = false; incomingPSController = false; l2capConnectionClaimed = false; sdpConnectionClaimed = false; rfcommConnectionClaimed = false; hci_state = HCI_SCANNING_STATE; } break;
d) In
EV_INQUIRY_COMPLETEhandler (around line 488), prevent the library from giving up after 5 failed inquiries for HID devices — change the block to only give up for Wii, and keep retrying for HID:case EV_INQUIRY_COMPLETE: if(inquiry_counter >= 5 && pairWithWii) { inquiry_counter = 0; connectToWii = false; pairWithWii = false; hci_state = HCI_SCANNING_STATE; } if(inquiry_counter >= 5 && pairWithHIDDevice) { // Don't give up — reset counter and keep searching inquiry_counter = 0; } inquiry_counter++; break;
-
BTD.h— makehci_stateaccessible from the sketch, and add link key storage (in theprivate:section, around line 587):/* Variables used by high level HCI task */ public: uint8_t hci_state; // Current state of Bluetooth HCI connection private:
Also add link key fields after
sspPasskey(around line 493):/** Stored link key for reconnection (set from EV_LINK_KEY_NOTIFICATION). */ uint8_t link_key[16]; /** BD_ADDR associated with stored link key. */ uint8_t link_key_bdaddr[6]; /** True if a valid link key is stored. */ bool link_key_valid;
And add function declarations after
hci_link_key_request_negative_reply():/** Reply to a Link Key Request with a stored link key. */ void hci_link_key_request_reply(); /** Enable encryption on an existing HCI connection (needed after link key auth). */ void hci_set_connection_encryption();
-
BTD.cpp— link key storage and reconnection support:a) In the constructor (around line 41), initialize link key fields:
link_key_valid = false; memset(link_key, 0, sizeof(link_key)); memset(link_key_bdaddr, 0, sizeof(link_key_bdaddr));
b) Replace
EV_LINK_KEY_REQUESThandler (around line 649) — reply with stored key when available:case EV_LINK_KEY_REQUEST: if(link_key_valid && memcmp(&hcibuf[2], link_key_bdaddr, 6) == 0) { hci_link_key_request_reply(); } else { hci_link_key_request_negative_reply(); } break;
c) Move
EV_LINK_KEY_NOTIFICATIONout of the ignored events list and add a handler to store the key:case EV_LINK_KEY_NOTIFICATION: for(uint8_t i = 0; i < 6; i++) link_key_bdaddr[i] = hcibuf[2 + i]; for(uint8_t i = 0; i < 16; i++) link_key[i] = hcibuf[8 + i]; link_key_valid = true; break;
d) Add
hci_link_key_request_reply()andhci_set_connection_encryption()functions afterhci_link_key_request_negative_reply():void BTD::hci_link_key_request_reply() { hcibuf[0] = 0x0B; // HCI OCF = 0B hcibuf[1] = 0x01 << 2; // HCI OGF = 1 hcibuf[2] = 0x16; // parameter length 22 hcibuf[3] = disc_bdaddr[0]; hcibuf[4] = disc_bdaddr[1]; hcibuf[5] = disc_bdaddr[2]; hcibuf[6] = disc_bdaddr[3]; hcibuf[7] = disc_bdaddr[4]; hcibuf[8] = disc_bdaddr[5]; for(uint8_t i = 0; i < 16; i++) hcibuf[9 + i] = link_key[i]; HCI_Command(hcibuf, 25); } void BTD::hci_set_connection_encryption() { hcibuf[0] = 0x13; // HCI OCF = 0x13 (Set_Connection_Encryption) hcibuf[1] = 0x01 << 2; // HCI OGF = 1 (Link Control) hcibuf[2] = 0x03; // parameter length 3 hcibuf[3] = (uint8_t)(hci_handle & 0xFF); hcibuf[4] = (uint8_t)((hci_handle >> 8) & 0x0F); hcibuf[5] = 0x01; // Encryption_Enable = ON HCI_Command(hcibuf, 6); }
e) In
EV_AUTHENTICATION_COMPLETEhandler, when reconnecting with stored link key, enable encryption and wait forEV_ENCRYPTION_CHANGEbefore starting L2CAP:} else if(pairWithHIDDevice && !connectToHIDDevice) { Notify(PSTR("\r\nPairing successful with HID device"), 0x80); if(link_key_valid) { // Reconnect — enable encryption first, wait for EV_ENCRYPTION_CHANGE hci_set_connection_encryption(); } else { // First SSP pairing — controller handles encryption automatically connectToHIDDevice = true; }
f) Move
EV_ENCRYPTION_CHANGEout of the ignored events list and add a handler — setconnectToHIDDeviceafter encryption is established:case EV_ENCRYPTION_CHANGE: if(hcibuf[2] == 0x00) { // Status = success Notify(PSTR("\r\nEncryption enabled"), 0x80); if(pairWithHIDDevice && !connectToHIDDevice) { connectToHIDDevice = true; } } else { Notify(PSTR("\r\nEncryption failed: "), 0x80); D_PrintHex<uint8_t>(hcibuf[2], 0x80); hci_disconnect(hci_handle); } break;
-
BTHID.h— makesetProtocol()virtual so the sketch can override it (Apple keyboards break when SET_PROTOCOL is sent on reconnect). In theprivate:section (around line 171), change:// WAS: void setProtocol(); virtual void setProtocol();
-
cdcacm.cpp— add protocol comparison flag to CDC control parser (around line 139). This fixes enumeration of BT dongles with non-standard CDC protocol values:// WAS: CP_MASK_COMPARE_SUBCLASS > CdcControlParser(this); CP_MASK_COMPARE_SUBCLASS | CP_MASK_COMPARE_PROTOCOL > CdcControlParser(this);
-
BTHID.cpp— also handle Input report responses (0xA1) on the L2CAP control channel, not just Feature (0xA3). Around line 376, change:// WAS: if(l2capinbuf[8] == 0xA3) { if(l2capinbuf[8] == 0xA3 || l2capinbuf[8] == 0xA1) {
-
BTD.handBTD.cpp— addedhci_write_link_policy(),hci_sniff_subrating(),hci_sniff_mode(), andhci_exit_sniff_mode(). Onlyhci_write_link_policyis called from the firmware — it sets the link policy to allow sniff + role switch (0x05) so the keyboard can negotiate its own power saving, just like macOS. The keyboard enters sniff mode automatically (~15ms interval = 24 slots × 0.625ms).hci_sniff_subrating()(SSR) is available for dongles that support it — the DFRobot TEL0002 does not (returns0x1AUnsupported Remote Feature).hci_sniff_mode()is NOT called because it returnsCommand Status(notCommand Complete), which breaks the BTD state machine. -
BTD.cpp— addedEV_MODE_CHANGE(0x14) andEV_SNIFF_SUBRATING(0x2E) event handlers. Logs mode transitions, sniff intervals, and SSR negotiation results underDEBUG_USB_HOST. Event mask updated (0x1F→0x3F) to enable bit 45 forEV_SNIFF_SUBRATING. Also added logging for failed HCI commands inEV_COMMAND_COMPLETEhandler.
Paste this sketch and upload it to the first Atom S3U:
#include <WiFi.h>
void setup() {
Serial.begin(115200);
delay(2000);
WiFi.mode(WIFI_STA);
Serial.println("\n===================");
Serial.println("MAC Address:");
Serial.println(WiFi.macAddress());
Serial.println("===================");
}
void loop() {
delay(5000);
Serial.println(WiFi.macAddress());
}Upload via Sketch → Upload, then open Tools → Serial Monitor and copy the MAC address (e.g. 3C:DC:75:ED:FB:4C). Save it.
Now swap to the second Atom S3U and repeat. Save both MAC addresses.
⚠️ Important: Before uploading, set these Arduino IDE options:
Setting Value USB Mode USB-OTG (TinyUSB) USB Upload Mode USB-OTG (TinyUSB) USB CDC On Boot Enabled (required — see Linux setup below)
Upload the following sketch to both Atom S3U devices:
📄 Atom S3U Receiver Sketch — click to expand
https://github.com/krll-kov/m5stack-wireless-kvm-switch/blob/main/ino_atoms3u.inoAfter upload you'll likely see this error — that's expected and means it worked:
"Port monitor error: command 'open' failed: no such file or directory."
💡 Need to re-flash later? Hold the reset button on the Atom S3U → insert into USB (keep holding) → wait 1 second → release. The port will reappear in Arduino.
Connect the CoreS3 SE to your Mac and select Tools → Board → M5Stack → M5CoreS3.
Before uploading, update these values in the sketch:
| Variable | Description | Example |
|---|---|---|
targets[] |
Array of PC targets: MAC, AES-128 key, bind button | See below |
BLE_KBD_MATCH |
Part of your keyboard's BT name (no special chars) | "Keyboard" |
USE_MAX_MODULE |
Set true if using the classic USB module |
false |
WITH_KEYBOARD |
Set false for mouse-only mode |
true |
BLE_PROBE_MIN_RSSI |
Raise to -80 if keyboard is far away |
-55 |
DEBUG_MODE |
Set to true to see debug mode stats (USB poll rate, ESP-NOW send rate, queue depth) | false |
Each PC is defined as a PCTarget struct with its AtomS3U MAC address, AES-128 key, and switch bind button:
static const PCTarget targets[] = {
// PC 1: AtomS3U MAC, AES-128 key, mouse button 4 (0x08) to switch
{{0x3C,0xDC,0x75,0xED,0xFB,0x4C}, {0x4B,0x56,0x4D,0x53,0x77,0x31,0x7A,0xDE,0xAD,0xBE,0xEF,0x42,0x13,0x37,0xCA,0xFE}, 0x08},
// PC 2: AtomS3U MAC, AES-128 key, mouse button 5 (0x10) to switch
{{0xD0,0xCF,0x13,0x0F,0x90,0x48}, {0x4B,0x56,0x4D,0x53,0x77,0x31,0x7A,0xDE,0xAD,0xBE,0xEF,0x42,0x13,0x37,0xCA,0xFE}, 0x10},
};To add more PCs, just add another line to the array.
Each PC has its own switch bind (mouse button):
- Press the bind for another PC → switches to that PC directly
- Press the bind for the current PC → cycles to next PC in the list
- Example: on PC1 (bind=mouse4), press mouse5 → go to PC2; on PC2 (bind=mouse5), press mouse5 → go to PC1
- M5 hardware button A always cycles to the next PC
- Set
bindMaskto0for no bind (switch only via M5 button or other PC's bind)
| Button | Bitmask | Hex |
|---|---|---|
| Mouse 4 (back) | bit 3 | 0x08 |
| Mouse 5 (forward) | bit 4 | 0x10 |
| Mouse 6 | bit 5 | 0x20 |
Security: Keyboard data is encrypted with AES-128-CTR (hardware-accelerated) over the air. Each PC has its own AES key — the key for each target must match the corresponding AtomS3U receiver. Mouse data is sent as unencrypted broadcast for maximum performance. Generate your own random 16-byte keys and set them in both
ino_cores3se.ino(targets[].aesKey) andino_atoms3u.ino(AES_KEY) — the values must match per PC. Using the same key for all PCs is fine but means a compromised key on one PC exposes all keyboard traffic.
📄 CoreS3 SE Main Controller Sketch — click to expand
https://github.com/krll-kov/m5stack-wireless-kvm-switch/blob/main/ino_cores3se.ino- Press the reset button once (don't hold) on the CoreS3 SE
- Plug in your mouse dongle to the CoreS3 USB port
- The device will auto-scan and pair your Bluetooth keyboard
- Make sure to "Forget" the keyboard in macOS Bluetooth settings first if it was previously paired with your Mac
| Issue | Explanation |
|---|---|
| Mouse lag during BT scan | The device has a single radio module shared between ESP-NOW (mouse) and BLE (keyboard). Lag stops once keyboard pairing completes. |
| Security PIN prompt | Some keyboards require entering a 6-digit PIN displayed on the CoreS3 screen. |
| First input delay after idle | The device enters power-saving mode after inactivity (10 sec = 1ms delay, 1 minute = 20ms delay, 5 min = 50ms delay, 15 min = 100ms delay). The first mouse movement after wake may feel slightly delayed. Mouse USB polling remains active at all idle levels (USB SOF keeps the dongle awake, and submitted transfers cost nothing when idle — NAK is handled in hardware). Classic BT keyboards negotiate sniff mode automatically with the dongle (enabled by setting link policy to allow sniff + role switch after connection) — the Apple Magic Keyboard enters sniff at ~15ms interval. Sniff Subrating (SSR) for deeper idle sleep is supported in code but the DFRobot TEL0002 dongle does not support it; a dongle with SSR support would allow the keyboard to skip sniff slots during extended idle. BLE keyboards use relaxed connection parameters at higher idle levels. All power-saving is transparent and reverts to full speed on activity. |
| Mouse rate | Mouse events are forwarded 1:1 at the native poll rate of your mouse (tested up to 1000Hz). ESP-NOW uses 6.5 Mbps HT20 PHY for sufficient wireless throughput, and the AtomS3U uses non-blocking USB sends to avoid frame-alignment bottlenecks. On Linux, the CDC serial port must be kept open for full speed — see Linux setup. |
| Apple fn/Globe key — dictation popup | The fn (Globe) key is forwarded as a consumer control key (usage 0x029D). A quick tap while typing may trigger the macOS "Enable Dictation?" dialog. To fix: go to System Settings → Keyboard → Dictation and turn it off, or change "Press fn key to" to "Change Input Source". You also have to select "Start Dictation - Press twice" and in bottom section change the shortcut to microphone, then switch back to "Change Input Source" |
| Battery status does not update | If you don't use debug mode, the only way to update the screen is to press mouse4 to switch pc or to plug-out/in mouse dongle, this is made for performance reasons. Also if you charge with battery base - we can't get the voltage and other info directly with code so we measure it by taking periodic battery samples. Apple keyboard battery is read from two HID reports: 0xF0 (periodic battery report) and 0x9B (device status report, byte 2 = battery %). |
| Screen goes black | This is done because of power efficiency - screen is only displayed during setup/pc switch (10sec here), without it battery will drain faster than it's charing from battery bottom |
| Unlock to use accessories MacOS lock-screen | One of the most recent MacOS updates has changed something and after a night of inactivity mouse and keyboard do not work on lock screen, to fix this you need to go to Settings → Privacy & Security → Accessories and switch it to "Always Allow" |
The AtomS3U uses the ESP32-S3 DWC2 USB controller, which has a hardware quirk: the HID endpoint (mouse/keyboard) only runs at full speed (~1000 Hz) when the CDC serial endpoint is actively polled by the host. Without this, mouse rate drops to ~500 Hz.
On macOS and Windows this happens automatically — the OS keeps CDC endpoints active.
On Linux you need to keep the CDC port open. Create a systemd service on each Linux PC where an AtomS3U is plugged in:
# 1. Create the service
sudo tee /etc/systemd/system/kvm-cdc.service << 'EOF'
[Unit]
Description=Keep KVM CDC port active for HID performance
After=dev-ttyACM0.device
BindsTo=dev-ttyACM0.device
[Service]
ExecStart=/bin/bash -c '/bin/cat /dev/ttyACM0 > /dev/null'
Restart=always
RestartSec=1
[Install]
WantedBy=dev-ttyACM0.device
EOF
# 2. Enable and start
sudo systemctl daemon-reload
sudo systemctl enable kvm-cdc.service
sudo systemctl start kvm-cdc.serviceIf your device appears as
/dev/ttyACM1instead ofttyACM0, replace both occurrences in the service file.To verify it's working, check
systemctl status kvm-cdc.service— it should show "active (running)".
┌──────────────┐ ESP-NOW ┌──────────────┐
│ Wireless │ ───────────────► │ AtomS3U #1 │──── USB ──── PC 1
│ Mouse │ └──────────────┘
│ (dongle) ──►│ CoreS3 SE ┌──────────────┐
│ │ ───────────────► │ AtomS3U #2 │──── USB ──── PC 2
│ Bluetooth │ ESP-NOW └──────────────┘
│ Keyboard ──►│ ...2+ PCs
└──────────────┘
[Mouse4] = PC 1 bind [Mouse5] = PC 2 bind
The CoreS3 SE acts as the central hub: it reads the USB mouse dongle and BLE keyboard, then forwards all HID events over ESP-NOW to whichever Atom S3U is currently active. Each Atom S3U appears as a standard USB keyboard + mouse to its host PC.