[DIY GUIDE] Hotswap Mechanical HID Macroboard for L1Techs KVM Hotkeys! (Adafruit)



[Learning & Testing] Example w/Info (Stemma QT, AT, TRRS, I2C Cables)

Intro

This guide is for using hotkey controls with the KVM when pushing the physical select button, and/or using keyboard hotkey control sequences is not an option. Thank you to Nick for reaching out to us!

I am also working to explore this same technology (Adafruit Trinkey) for a 4-key MX macropad to enable hotkey controls when your daily driver keyboard is not HID compatible. See the next section once I figure that out (hopefully soon!)

Anyway! Instead of reinventing the wheel, we have a great resource from Adafruit by way of Stemma QT technology. In fact, they have a large repertoire of interactive and innovative ways to control various IOT devices.

The goal is to create a HID input/keyboard so that KVM hotkeys can be used with a TRRS 3.5mm AT device via HID. In this case, I will be using a thumbstick from Adafruit in which movement will trigger the hotkey. Instead of “Left_Ctrl Left Ctrl 1” on your keyboard, we should be able to move the thumbstick. As noted in their article(s) this can be adapted for other use cases and needs.

By using an Adafruit TRRS Trinkey running custom firmware, we can turn it into a USB keyboard with exactly one USB interface. No serial port, no extra composite interfaces, It enumerates purely as a HID keyboard, which is what allows it to work through the L1Techs KVM’s USB-HID port.

For more technical information about the specific parts and how they work, please see the attached articles under Resources.

Parts List

Adafruit TRRS Trinkey

RCA Cable

Thumb Joystick with Select Button

(or whatever you would like to use to control. Keep in mind this WILL require different programming). This is just an example device.

Assembly

Joystick Wiring


credit: Adafruit Learning System




The TRRS Trinkey exposes four GPIO-connected contacts through its 3.5mm jack. There is no dedicated power or ground, so any power/ground attached peripheral needs has to be driven by two of those four GPIOs.

TRRS contact Connected to Role in this build
Tip Thumbstick Xout Analog input, X axis
Ring 1 Thumbstick Yout Analog input, Y axis
Ring 2 Thumbstick GND GPIO driven LOW as virtual ground
Sleeve Thumbstick VCC GPIO driven HIGH as virtual 3.3V supply

This wiring follows Adafruit’s official Adaptive Joysticks guide for the TRRS Trinkey. Because all four contacts are committed to X, Y, GND, and VCC, there is no fifth conductor available for the thumbstick’s separate SEL (click) button in this configuration.

Prerequisites Setting up the Adafruit TRRS Trinkey

Download:

  • Adafruit SAMD Boards
  • Adafruit TinyUSB Library
  • NeoPixel is just for status readout

Board: Adafruit TRRS Trinkey
Tools menu: Board → Adafruit SAMD Boards → Adafruit TRRS Trinkey M0 (Board Manager)
USB Stack → TinyUSB
Library: Adafruit TinyUSB Library + Adafruit NeoPixel (Library Manager)

Sketch Notes

The L1Techs KVM’s USB-HID is Strict

Some KVM switches, especially ones used in secure, compliance-sensitive environments, implement an intentionally narrow USB HID filter. They are designed to accept simple keyboards and mice and to reject anything that looks like it could be used to exfiltrate data or inject unexpected traffic. A common way they do this is by refusing composite USB devices outright. If a device presents more than one interface (HID + CDC) the KVM’s USB firewall drops the whole device rather than trying to figure out which interface is “safe.”

This matters because most Arduino boards using TinyUSB (including the stock TRRS Trinkey firmware) establishes the Trinkey as a composite devices by default. The Arduino core automatically adds a CDC interface alongside HID so that Serial.print() worls. That is convenient for development, but unfortunately is also the kind of multi-interface that a strict KVM’s USB filter is built to reject. Without careful planning, a USB device would show up fine on a normal PC, but be invisible or malfunctioning on the KVM’s USB-HID port.
^^ this is very common when people ask us "why doesn’t my device work?’

Why TinyUSB specifically, instead of Arduino USB

The SAMD21 chip on the Trinkey doesn’t have hardware USB HID support baked in. That is well, a problem. It is a better option than the “Arduino USB” stack, though. HID only is impossible. This is because it hard-codes a composite CDC+HID layout with very little control available via the sketch code too. You can’t just say “don’t add the CDC interface.”

Adafruit TinyUSB library is a different USB stack that exposes device configuration as first-class, public API calls: setID(), clearConfiguration(), and the ability to control which interfaces get added and in what order.

Default Arduino USB stack Adafduit TinyUSB
Interfaces added by default CDC + HID (composite) Only what the sketch explicitly adds
Can drop the CDC interface? No public API to do this Yes, clearConfiguration() before adding HID
USB IDs / strings Fixed by board package Fully settable at runtime (setID, string descriptors)

How to Setup Single-Interface HID

    1. Set a fixed identity: setID(), setManufacturerDescriptor(), setProductDescriptor(), and setSerialDescriptor() are called first, so the device reports the same VID/PID and strings every time it boots, on every host. This avoids Windows treating it as a “new” device on each reconnect.
    1. Wipe the default configuration: TinyUSBDevice.clearConfiguration() removes the CDC interface that the Arduino/TinyUSB init would otherwise add automatically.
    1. Add only HID: usb_hid.begin() adds a single HID interface, configured as a boot-protocol keyboard via a minimal TUD_HID_REPORT_DESC_KEYBOARD() report descriptor.
    1. Force clean: detach() / delay(500) / attach() makes the host re-read the descriptors from scratch, so it sees the final single-interface configuration rather than a partially-updated one.

The result is a device descriptor reporting one interface: bInterfaceClass 0x03 (HID), bInterfaceSubClass 0x01 (Boot Interface), bInterfaceProtocol 0x01 (Keyboard

Setting up advanced macro sequences

i.e., needing to cycle through active ports (PC 1 → PC 2 → PC 3 → PC 4) with 1 hotkey.

const uint8_t macro_sequence[] = {
  TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2,
  TAP_DELAY_1S,
  HID_KEY_ARROW_DOWN,
  TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2
};

So the full sequence is:

Left Control + Left Control + F2 + Down Arrow + Left Control + Left Control + F2.

^^ We will use this with the macroboard.

Explainer:

  1. Left Control + Left Control + F2
    This tells the KVM “I want to manually change ports. Wait for my input”

  2. 1 Second Delay
    I added a 1-second delay just to make sure there’s sufficient time for the KVM to go into “wait” mode.

  3. Down Arrow
    This tells the KVM I want to switch down to the next active (green light) PC.
    If it is already on the last active PC, it will wrap around to select the 1st available active PC. Like in my video, it was switching from PC 1 to PC 2.
    If it were a 4-port KVM and only PC 2 and PC 4 were active, it would alternate between them.

  4. Left Control + Left Control + F2
    Doing this for a 2nd time takes the KVM out of the manual switching inputs/waiting mode and puts it into regular use mode.

I referenced my hotkeys post to find the desired sequence in this example:

Sketch

//Necessary libraries
#include "Adafruit_TinyUSB.h"
#include <Adafruit_NeoPixel.h>

// USB identity from Adafruit
// The VID/PID/strings are hard-coded rather than left at Adafruit's stock
#define MY_USB_VID 0x239A // Adafruit VID
#define MY_USB_PID 0x8115 // TRRS Trinkey PID
#define MY_USB_MANUFACTURER "Custom"
#define MY_USB_PRODUCT "TRRS Trinkey Keyboard"
#define MY_USB_SERIAL "TRRSHID0001"

// HID setup
uint8_t const desc_hid_report[] = {
  TUD_HID_REPORT_DESC_KEYBOARD()
};

Adafruit_USBD_HID usb_hid;

// Thumbstick defining
#define PIN_X PIN_TIP // analog
#define PIN_Y PIN_RING1 // analog
#define PIN_GND PIN_RING2 // driven LOW as virtual ground
#define PIN_VCC PIN_SLEEVE // driven HIGH as virtual power

// Macro played on movement
#define TAP_MOD_LCTRL 0xFF  // sentinel, not a real HID keycode
const uint8_t macro_sequence[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_1 };
#define MACRO_LEN (sizeof(macro_sequence) / sizeof(macro_sequence[0]))

#define TAP_HOLD_MS 30  // how long each key is held down
#define TAP_GAP_MS  30  // gap between taps

// Movement logic
// analogReadResolution(12) below -> readings are 0-4095.

// Deadzone
#define ADC_BITS      12
#define DEADZONE      300
#define DEBOUNCE_MS   20

int centerX = 0, centerY = 0;

// NeoPixel for status feedback (since we have no Serial)
Adafruit_NeoPixel pixel(1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800);

void setup() {
  pixel.begin();
  pixel.setBrightness(20);
  pixel.setPixelColor(0, pixel.Color(20, 0, 0)); // red = booting
  pixel.show();

  // PINOUT
  pinMode(PIN_GND, OUTPUT);
  digitalWrite(PIN_GND, LOW);
  pinMode(PIN_VCC, OUTPUT);
  digitalWrite(PIN_VCC, HIGH);

  analogReadResolution(ADC_BITS);
  delay(50); // let the pot rails settle after power-up

  // Calibrate thumbstick
  // On boot, the firmware assumes the thumbstick is at rest/centered. If the stick is nudged during power-up, calibration will be off. Power-cycling with the stick released fixes this.
  long sumX = 0, sumY = 0;
  const int SAMPLES = 16;
  for (int i = 0; i < SAMPLES; i++) {
    sumX += analogRead(PIN_X);
    sumY += analogRead(PIN_Y);
    delay(2);
  }
  centerX = sumX / SAMPLES;
  centerY = sumY / SAMPLES;

// Fixed identity
TinyUSBDevice.setID(MY_USB_VID, MY_USB_PID);
  TinyUSBDevice.setManufacturerDescriptor(MY_USB_MANUFACTURER);
  TinyUSBDevice.setProductDescriptor(MY_USB_PRODUCT);
  TinyUSBDevice.setSerialDescriptor(MY_USB_SERIAL);

  // Important: strip the auto-added CDC interface
  TinyUSBDevice.clearConfiguration();

  // Add ONLY the HID interface
  usb_hid.setPollInterval(2);
  usb_hid.setBootProtocol(HID_ITF_PROTOCOL_KEYBOARD);
  usb_hid.setReportDescriptor(desc_hid_report, sizeof(desc_hid_report));
  usb_hid.setStringDescriptor(MY_USB_PRODUCT);
  usb_hid.begin();

  // Force clean for HID
  TinyUSBDevice.detach();
  delay(500);
  TinyUSBDevice.attach();

  pixel.setPixelColor(0, pixel.Color(0, 20, 0)); // green = ready
  pixel.show();
}

bool last_stable_state = false; // false = not moved
bool last_raw_state = false;
uint32_t last_change_ms = 0;

// Sends one keystroke: press, hold briefly, release, then
// pause before the next tap. If keycode == TAP_MOD_LCTRL, taps the
// Left Ctrl modifier instead of a regular key.
0void sendTap(uint8_t keycode) {
  uint8_t keys[6] = { 0 };
  uint8_t modifier = 0;

  if (keycode == TAP_MOD_LCTRL) {
    modifier = KEYBOARD_MODIFIER_LEFTCTRL;
  } else {
    keys[0] = keycode;
  }

  usb_hid.keyboardReport(0, modifier, keys);
  delay(TAP_HOLD_MS);

  uint8_t empty[6] = { 0 };
  usb_hid.keyboardReport(0, 0, empty);
  delay(TAP_GAP_MS);
}

void playMacro() {
  for (uint8_t i = 0; i < MACRO_LEN; i++) {
    sendTap(macro_sequence[i]);
  }
}

void loop() {
// Each loop iteration compares the current X/Y
#ifdef TINYUSB_NEED_POLLING_TASK
  TinyUSBDevice.task();
#endif

  delay(2);
  if (!usb_hid.ready()) return;

  int x = analogRead(PIN_X);
  int y = analogRead(PIN_Y);

  bool raw_moved = (abs(x - centerX) > DEADZONE) ||
                    (abs(y - centerY) > DEADZONE);

  // Debounce
  if (raw_moved != last_raw_state) {
    last_raw_state = raw_moved;
    last_change_ms = millis();
  }

  if ((millis() - last_change_ms) >= DEBOUNCE_MS &&
      raw_moved != last_stable_state) {
    last_stable_state = raw_moved;

    if (last_stable_state) {
      // Just moved off-center: fire the macro once
      pixel.setPixelColor(0, pixel.Color(0, 0, 20)); // blue = macro firing
      pixel.show();
      playMacro();
    } else {
      // Back to center: ready to fire again next time it moves
      pixel.setPixelColor(0, pixel.Color(0, 20, 0)); // green = idle/ready
      pixel.show();
    }
  }
}

Compiling

Sketch uses 43956 bytes (16%) of program storage space. Maximum is 262144 bytes.
Device       : ATSAMD21x18
Version      : v1.1 [Arduino:XYZ] Apr 17 2024 12:09:58
Address      : 0x0
Pages        : 4096
Page Size    : 64 bytes
Total Size   : 256KB
Planes       : 1
Lock Regions : 16
Locked       : none
Security     : false
BOD          : true
BOR          : true
Write 44404 bytes to flash (694 pages)

[                              ] 0% (0/694 pages)
[==                            ] 9% (64/694 pages)
[=====                         ] 18% (128/694 pages)
[========                      ] 27% (192/694 pages)
[===========                   ] 36% (256/694 pages)
[=============                 ] 46% (320/694 pages)
[================              ] 55% (384/694 pages)
[===================           ] 64% (448/694 pages)
[======================        ] 73% (512/694 pages)
[========================      ] 82% (576/694 pages)
[===========================   ] 92% (640/694 pages)
[==============================] 100% (694/694 pages)
Done in 7.715 seconds
Verify 44404 bytes of flash

[==============================] 100% (694/694 pages)
Verify successful
Done in 0.497 seconds

Reprogramming Info

Since CDC is intentionally never started, Arduino IDE’s automatic “touch at 1200 baud” reset trick doesn’t work. To upload new firmware:

  • Double-tap the RESET button on the Trinkey to force it into bootloader mode.
  • The board should appear as a small USB mass-storage drive and be detected by the Arduino IDE’s port list while in bootloader mode.
  • Upload as normal from the Arduino IDE with Board = Adafruit TRRS Trinkey M0 (SAMD 21) and USB Stack = TinyUSB.

HID USBTreeView

    =========================== USB Port2 ===========================

Connection Status        : 0x01 (Device is connected)
Port Chain               : 2-5-1-2
Properties               : 0x00
 IsUserConnectable       : no
 PortIsDebugCapable      : no
 PortHasMultiCompanions  : no
 PortConnectorIsTypeC    : no
ConnectionIndex          : 0x02 (Port 2)

      ========================== Summary =========================
Vendor ID                : 0x239A (Adafruit Industries LLC)
Product ID               : 0x8157
Manufacturer String      : "Adafruit"
Product String           : "TRRS Trinkey M0"
Serial                   : "5C35BDC5503055314E2E3120FF0E2A01"
USB Version              : 2.0 (but 12 Mbit/s FullSpeed only)
Port maximum Speed       : Full-Speed
Device maximum Speed     : Full-Speed
Device Connection Speed  : Full-Speed
Self powered             : no
Demanded Current         : 100 mA
Used Endpoints           : 2

      ======================== USB Device ========================

        +++++++++++++++++ Device Information ++++++++++++++++++
Device Description       : USB Input Device
BusReported Device Desc  : TRRS Trinkey M0
Device Path              : \\?\USB#VID_239A&PID_8157#5C35BDC5503055314E2E3120FF0E2A01#{a5dcbf10-6530-11d2-901f-00c04fb951ed} (GUID_DEVINTERFACE_USB_DEVICE)
Kernel Name              : \Device\USBPDO-12
Device ID                : USB\VID_239A&PID_8157\5C35BDC5503055314E2E3120FF0E2A01
Hardware IDs             : USB\VID_239A&PID_8157&REV_0100 USB\VID_239A&PID_8157
Driver KeyName           : {745a17a0-74d3-11d0-b6fe-00a0c90f57da}\0016 (GUID_DEVCLASS_HIDCLASS)
Driver                   : \SystemRoot\System32\drivers\hidusb.sys (Version: 10.0.26100.8521  Date: 2026-06-30  Company: Microsoft Corporation)
Driver Inf               : C:\WINDOWS\inf\input.inf
Legacy BusType           : PNPBus
Class                    : HIDClass
Class GUID               : {745a17a0-74d3-11d0-b6fe-00a0c90f57da} (GUID_DEVCLASS_HIDCLASS)
Service                  : HidUsb
Enumerator               : USB
Location Info            : Port_#0002.Hub_#0008
Address                  : 2
Location IDs             : PCIROOT(0)#PCI(0102)#PCI(0000)#PCI(0800)#PCI(0003)#USBROOT(0)#USB(5)#USB(1)#USB(2), ACPI(_SB_)#ACPI(PCI0)#ACPI(BXBR)#ACPI(BYUP)#ACPI(BYD8)#ACPI(XHC0)#ACPI(RHUB)#ACPI(PRT5)#USB(1)#USB(2)
Container ID             : {50aac64b-11ff-5b38-9e21-614ef20a1217}
Manufacturer Info        : (Standard system devices)
Capabilities             : 0x94 (Removable, UniqueID, SurpriseRemovalOK)
Status                   : 0x0180400A (DN_DRIVER_LOADED, DN_STARTED, DN_REMOVABLE, DN_NT_ENUMERATOR, DN_NT_DRIVER)
First Install Date       : 2026-07-02 18:56:57
Last Arrival Date        : 2026-07-02 19:31:00
SelectiveSuspendEnabled  : 0
EnhancedPowerMgmtEnabled : 1
Power State              : D0 (supported: D0, D1, D2, D3, wake from D0, wake from D1, wake from D2)

        +++++++++++++++++ Registry USB Flags +++++++++++++++++
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\usbflags\239A81570100
 osvc                    : REG_BINARY 00 00

        ---------------- Connection Information ---------------
Connection Index         : 0x02 (Port 2)
Connection Status        : 0x01 (DeviceConnected)
Current Config Value     : 0x01 (Configuration 1)
Device Address           : 0x06 (6)
Is Hub                   : 0x00 (no)
Device Bus Speed         : 0x01 (Full-Speed)
Number of open Pipes     : 0x01 (1 pipe to data endpoints)
Pipe[0]                  : EndpointID=1  Direction=IN   ScheduleOffset=0  Type=Interrupt  wMaxPacketSize=0x40    bInterval=2   -> 718 Bits/ms = 89750 Bytes/s

        --------------- Connection Information V2 -------------
Connection Index         : 0x02 (2)
Length                   : 0x10 (16 bytes)
SupportedUsbProtocols    : 0x01
 Usb110                  : 1 (yes, port supports USB 1.1)
 Usb200                  : 0 (no, port not supports USB 2.0)
 Usb300                  : 0 (no, port not supports USB 3.0)
 ReservedMBZ             : 0x00
Flags                    : 0x00
 DevIsOpAtSsOrHigher     : 0 (Device is not operating at SuperSpeed or higher)
 DevIsSsCapOrHigher      : 0 (Device is not SuperSpeed capable or higher)
 DevIsOpAtSsPlusOrHigher : 0 (Device is not operating at SuperSpeedPlus or higher)
 DevIsSsPlusCapOrHigher  : 0 (Device is not SuperSpeedPlus capable or higher)
 ReservedMBZ             : 0x00

    ---------------------- Device Descriptor ----------------------
bLength                  : 0x12 (18 bytes)
bDescriptorType          : 0x01 (Device Descriptor)
bcdUSB                   : 0x200 (USB Version 2.0) -> but device is Full-Speed only
bDeviceClass             : 0x00 (defined by the interface descriptors)
bDeviceSubClass          : 0x00
bDeviceProtocol          : 0x00
bMaxPacketSize0          : 0x40 (64 bytes)
idVendor                 : 0x239A (Adafruit Industries LLC)
idProduct                : 0x8157
bcdDevice                : 0x0100
iManufacturer            : 0x01 (String Descriptor 1)
 Language 0x0409         : "Adafruit"
iProduct                 : 0x02 (String Descriptor 2)
 Language 0x0409         : "TRRS Trinkey M0"
iSerialNumber            : 0x03 (String Descriptor 3)
 Language 0x0409         : "5C35BDC5503055314E2E3120FF0E2A01"
bNumConfigurations       : 0x01 (1 Configuration)

    ------------------ Configuration Descriptor -------------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x02 (Configuration Descriptor)
wTotalLength             : 0x0022 (34 bytes)
bNumInterfaces           : 0x01 (1 Interface)
bConfigurationValue      : 0x01 (Configuration 1)
iConfiguration           : 0x00 (No String Descriptor)
bmAttributes             : 0xA0
 D7: Reserved, set 1     : 0x01
 D6: Self Powered        : 0x00 (no)
 D5: Remote Wakeup       : 0x01 (yes)
 D4..0: Reserved, set 0  : 0x00
MaxPower                 : 0x32 (100 mA)

        ---------------- Interface Descriptor -----------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x04 (Interface Descriptor)
bInterfaceNumber         : 0x00 (Interface 0)
bAlternateSetting        : 0x00
bNumEndpoints            : 0x01 (1 Endpoint)
bInterfaceClass          : 0x03 (HID - Human Interface Device)
bInterfaceSubClass       : 0x01 (Boot Interface)
bInterfaceProtocol       : 0x01 (Keyboard)
iInterface               : 0x04 (String Descriptor 4)
 Language 0x0409         : "TRRS Trinkey M0"

        ------------------- HID Descriptor --------------------
bLength                  : 0x09 (9 bytes)
bDescriptorType          : 0x21 (HID Descriptor)
bcdHID                   : 0x0111 (HID Version 1.11)
bCountryCode             : 0x00 (00 = not localized)
bNumDescriptors          : 0x01
Descriptor 1:
bDescriptorType          : 0x22 (Class=Report)
wDescriptorLength        : 0x0041 (65 bytes)
Error reading descriptor : ERROR_INVALID_PARAMETER (due to a obscure limitation of the Win32 USB API, see F1 Help)

        ----------------- Endpoint Descriptor -----------------
bLength                  : 0x07 (7 bytes)
bDescriptorType          : 0x05 (Endpoint Descriptor)
bEndpointAddress         : 0x81 (Direction=IN EndpointID=1)
bmAttributes             : 0x03 (TransferType=Interrupt)
wMaxPacketSize           : 0x0040 (64 bytes)
bInterval                : 0x02 (2 ms)

    ----------------- Device Qualifier Descriptor -----------------
Error                    : request skipped because low-speed device

      ---------- Binary Object Store (BOS) Descriptor -----------
bLength                  : 0x05 (5 bytes)
bDescriptorType          : 0x0F (Binary Object Store)
wTotalLength             : 0x0039 (57 bytes)
bNumDeviceCaps           : 0x02

        ----------- Platform Capabilities Descriptor -----------
bLength                  : 0x18 (24 bytes)
bDescriptorType          : 0x10 (Device Capability Descriptor)
bDevCapabilityType       : 0x05 (Platform Capabilities)
bReserved                : 0x00
PlatformCapabilityUUID   : 3408B638-09A9-47A0-8BFD-A0768815B665
CapabililityData         : 0

        ----------- Platform Capabilities Descriptor -----------
bLength                  : 0x1C (28 bytes)
bDescriptorType          : 0x10 (Device Capability Descriptor)
bDevCapabilityType       : 0x05 (Platform Capabilities)
bReserved                : 0x00
PlatformCapabilityUUID   : D8DD60DF-4589-4CC7-9CD2-659D9E648A9F (GUID_USB_MSOS20_PLATFORM_CAPABILITY_ID)
dwWindowsVersion         : 0x06030000 (Windows V6.3 and higher)
wMSOSDescSetTotalLength  : 0x00B2 (178 Bytes)
bMS_VendorCode           : 0x02 (2)
bAltEnumCode             : 0x00 (0)

      -------------------- String Descriptors -------------------
             ------ String Descriptor 0 ------
bLength                  : 0x04 (4 bytes)
bDescriptorType          : 0x03 (String Descriptor)
Language ID[0]           : 0x0409 (English - United States)
             ------ String Descriptor 1 ------
bLength                  : 0x12 (18 bytes)
bDescriptorType          : 0x03 (String Descriptor)
Language 0x0409          : "Adafruit"
             ------ String Descriptor 2 ------
bLength                  : 0x20 (32 bytes)
bDescriptorType          : 0x03 (String Descriptor)
Language 0x0409          : "TRRS Trinkey M0"
             ------ String Descriptor 3 ------
bLength                  : 0x42 (66 bytes)
bDescriptorType          : 0x03 (String Descriptor)
Language 0x0409          : "5C35BDC5503055314E2E3120FF0E2A01"
             ------ String Descriptor 4 ------
bLength                  : 0x20 (32 bytes)
bDescriptorType          : 0x03 (String Descriptor)
Language 0x0409          : "TRRS Trinkey M0"

Working!!

Additional Notes

  • Status LED (NeoPixel)
    • Red = booting / calibrating
    • Green = Idle, centered, ready
    • Blue = Macro firing (stick moved off-center)
  • Only movement, not the thumbstick’s click is wired up. All four TRRS contacts are already committed to X, Y, GND, and VCC.
  • No serial debugging is available by design. NeoPixel is the only diagnostic tool.
  • Calibration is a one-time, boot-time snapshot of the thumb stick’s location. It does not adapt if the stick’s centered position drifts afterward. Power-cycle to recalibrate.
  • The macro is fixed at three taps (Left Ctrl, Left Ctrl, “1”). anything more elaborate would need to be re-flashed.

[DIY GUIDE] Hotswap Mechanical HID Macroboard for KVM Hotkeys!

Thanks to the knowledge gained in this endeavour, I now have enough information to make a functioning plug-and-play DIY macroboard with NO soldering.

The goal is to have the most common hotkeys ready and consistent to fire. I want to make sure to add in nice features like LED indicators when hotkeys fire (NeoPixel) and the ability to enable/disable standby LEDs if you do not want them.

Parts List

1x Adafruit TRRS Trinkey

1x NeoKey 1x4 QT I2C

1x Stemma QT JST SH I2C Cable

Mechanical Key Switches (Any Cherry Compatible)

I used the Adafruit Kailh Thick Click Jade Box

Keycaps (Any Cherry Compatible)

I used the Relegendable Plastic Keycaps. I like these because they have a white base with a protective clear top. You can get creative with a design or icon and have it protected.

Assembly – Very easy!

Just snap it all together.

The I2C cable is plugged into the right side, where the ON LED is in the top right corner.


Be sure to avoid bending the mechanical key pins when plugging into the NeoKey.






option 1

option 2

NeoKey Housing 3D Print

neokey housing final OBJ.zip (342.7 KB)
Note: to see the LEDs well a translucent filament would be best to use.

Added support to lock the neokey into place.

*Based on: https://www.thingiverse.com/thing:4948558

Default Mappings

#define COLOR_OFF         neokey.pixels.Color(0,   0,   0)   // standby color
#define COLOR_RED         neokey.pixels.Color(255, 0,   0)   // key1 L1
#define COLOR_ORANGE      neokey.pixels.Color(255, 70,  0)   // key1 L2
#define COLOR_GREEN       neokey.pixels.Color(0,   255, 0)   // key2 L1
#define COLOR_LIME        neokey.pixels.Color(150, 255, 150)   // key2 L2
#define COLOR_BLUE        neokey.pixels.Color(0,   0,   255) // key3 L1
#define COLOR_LIGHT_BLUE  neokey.pixels.Color(150,  217, 255) // key3 L2
#define COLOR_PURPLE      neokey.pixels.Color(150, 0,   220) // key4 waiting
#define COLOR_BOOT        neokey.pixels.Color(255, 255, 255) // boot flash

LAYER 1

Key 1 - Select next active port

  • LED Color:
    Red

  • Mapped Hotkey:
    Left Control + Left Control + F2 + Arrow Down + Left Control + Left Control + F2

  • Code:
    const uint8_t macro_sequence[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2, HID_KEY_ARROW_DOWN, TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2 };

Key 2 - Force audio to the current active port (Single beep = success, double beep = hotkey is disabled). Enable with layer 2 command.

Will not fire unless you use the hotkey that enables this feature. (Key 4 active + Key 2).

  • LED Color:
    Green

  • Mapped Hotkey:
    Left Control + Left Control + A

  • Code:
    const uint8_t macro_key2_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_A };

Key 3 - Force USBs to the current active port (Single beep = success, double beep = hotkey is disabled). Enable with layer 2 command.

Will not successfully fire unless you use the hotkey that enables this feature. (Key 4 active + Key 3).

  • LED Color:
    Blue

  • Mapped Hotkey:
    Left Control + Left Control + U

  • Code:
    const uint8_t macro_key2_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_U };

Key 4 - Enable modifier (change hotkey layer)

  • LED Color:
    Purple

  • Function:
    Once you press key 4, it will light up purple and wait for you to then press key 1, 2, or 3.
    Key 1, 2, or 3 will start its layer 2 hotkey.


LAYER 2

Key 4 active + Key 1 - Enable (single beep) or disable (double beep) KVM beeping sounds

  • LED Color:
    Orange

  • Mapped Hotkey:
    Left Control + Left Control + Left Alt (hold) + G + Left Alt (release)

  • Code:
    sendTap(TAP_MOD_LCTRL);
    sendTap(TAP_MOD_LCTRL);
    sendComboTap(KEYBOARD_MODIFIER_LEFTALT, HID_KEY_G);

Key 4 active + Key 2 - Enable (single beep) or disable (double beep) force Audio routing

  • LED Color:
    Light Green

  • Mapped Hotkey:
    Left Control + Left Control + F5

  • Code:
    const uint8_t macro_key2_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F5 };

Key 4 active + Key 3 - Enable (single beep) or disable (double beep) force USB routing

  • LED Color:
    Light Blue

  • KVM Hotkey:
    Left Control + Left Control + F7

  • Code:
    const uint8_t macro_key2_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F7 };

Key 4 active + Key 4 - Cancel layer 2 modifier.

  • LED Color:
    Purple

If key 4 is currently waiting for an input (LED is purple), pressing it again will cancel.

Additional Feature

  • Pressing and holding Key 4 for 1 second will allow the standby LEDs to be OFF, DIM, or BRIGHT

Sketch

#include "Adafruit_TinyUSB.h"
#include <Adafruit_NeoPixel.h>
#include <Wire.h>
#include <Adafruit_NeoKey_1x4.h>

#define MY_USB_VID          0x239A   // Adafruit VID
#define MY_USB_PID          0x8115   // TRRS Trinkey PID
#define MY_USB_MANUFACTURER "Custom"
#define MY_USB_PRODUCT      "TRRS Trinkey Keyboard"
#define MY_USB_SERIAL       "TRRSHID0002"

uint8_t const desc_hid_report[] = {
  TUD_HID_REPORT_DESC_KEYBOARD()
};

Adafruit_USBD_HID usb_hid;

#define NEOKEY_ADDR 0x30
Adafruit_NeoKey_1x4 neokey;

const uint8_t KEY_INDEX[4] = { 0, 1, 2, 3 };
#define KEY1 KEY_INDEX[0]
#define KEY2 KEY_INDEX[1]
#define KEY3 KEY_INDEX[2]
#define KEY4 KEY_INDEX[3]

#define TAP_MOD_LCTRL 0xFF  // sentinel, not a real HID keycode
#define TAP_DELAY_1S  0xFE  // sentinel, not a real HID keycode

#define TAP_HOLD_MS 30
#define TAP_GAP_MS  30

const uint8_t macro_key1_L1[] = {
  TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2,
  HID_KEY_ARROW_DOWN,
  TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F2
};
const uint8_t macro_key2_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_A };
const uint8_t macro_key3_L1[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_U };
const uint8_t macro_key2_L2[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F5 };
const uint8_t macro_key3_L2[] = { TAP_MOD_LCTRL, TAP_MOD_LCTRL, HID_KEY_F7 };

#define MACRO_LEN(arr) (sizeof(arr) / sizeof(arr[0]))

const uint8_t STANDBY_LEVELS[3] = { 0, 50, 255 };  // off, dim, bright
uint8_t standbyLevelIndex = 0;
uint8_t standbyLevel = STANDBY_LEVELS[0];

// Macro-fire colors are always full brightness, independent of the standby
// stage -- only the idle/off pixel color (COLOR_OFF, white-only) follows
// standbyLevel.
#define COLOR_RED         neokey.pixels.Color(255, 0,   0)   // key1 L1
#define COLOR_ORANGE      neokey.pixels.Color(255, 70,  0)   // key1 L2
#define COLOR_GREEN       neokey.pixels.Color(0,   255, 0)   // key2 L1
#define COLOR_LIME        neokey.pixels.Color(140, 255, 0)   // key2 L2
#define COLOR_BLUE        neokey.pixels.Color(0,   0,   255) // key3 L1
#define COLOR_LIGHT_BLUE  neokey.pixels.Color(60,  170, 255) // key3 L2
#define COLOR_PURPLE      neokey.pixels.Color(150, 0,   220) // key4 waiting
#define COLOR_BOOT        neokey.pixels.Color(255, 255, 255) // boot flash

#define COLOR_OFF (neokey.pixels.Color(standbyLevel, standbyLevel, standbyLevel))

bool waitingForLayer2 = false;
uint32_t waitStartMs = 0;
#define LAYER2_TIMEOUT_MS 5000

bool key4Down = false;
uint32_t key4PressStart = 0;
bool key4HoldFired = false;
#define HOLD_MS 1000

bool last_raw_state[4]    = { false, false, false, false };
bool last_stable_state[4] = { false, false, false, false };
uint32_t last_change_ms[4] = { 0, 0, 0, 0 };
#define DEBOUNCE_MS 25

void setup() {
  Wire.begin();

  if (!neokey.begin(NEOKEY_ADDR)) {
    while (1) {
      delay(500);
    }
  }

  for (uint8_t i = 0; i < 4; i++) {
    neokey.pixels.setPixelColor(i, COLOR_BOOT);
  }
  neokey.pixels.show();
  delay(300);
  refreshIdlePixels();

  TinyUSBDevice.setID(MY_USB_VID, MY_USB_PID);
  TinyUSBDevice.setManufacturerDescriptor(MY_USB_MANUFACTURER);
  TinyUSBDevice.setProductDescriptor(MY_USB_PRODUCT);
  TinyUSBDevice.setSerialDescriptor(MY_USB_SERIAL);

  TinyUSBDevice.clearConfiguration();

  usb_hid.setPollInterval(2);
  usb_hid.setBootProtocol(HID_ITF_PROTOCOL_KEYBOARD);
  usb_hid.setReportDescriptor(desc_hid_report, sizeof(desc_hid_report));
  usb_hid.setStringDescriptor(MY_USB_PRODUCT);
  usb_hid.begin();

  TinyUSBDevice.detach();
  delay(500);
  TinyUSBDevice.attach();
}

void sendTap(uint8_t keycode) {
  uint8_t keys[6] = { 0 };
  uint8_t modifier = 0;

  if (keycode == TAP_MOD_LCTRL) {
    modifier = KEYBOARD_MODIFIER_LEFTCTRL;
  } else {
    keys[0] = keycode;
  }

  usb_hid.keyboardReport(0, modifier, keys);
  delay(TAP_HOLD_MS);

  uint8_t empty[6] = { 0 };
  usb_hid.keyboardReport(0, 0, empty);
  delay(TAP_GAP_MS);
}

void sendComboTap(uint8_t modifier, uint8_t keycode) {
  uint8_t keys[6] = { 0 };
  keys[0] = keycode;

  usb_hid.keyboardReport(0, modifier, keys);
  delay(TAP_HOLD_MS);

  uint8_t empty[6] = { 0 };
  usb_hid.keyboardReport(0, 0, empty);
  delay(TAP_GAP_MS);
}

void playMacro(const uint8_t* seq, uint8_t len) {
  for (uint8_t i = 0; i < len; i++) {
    if (seq[i] == TAP_DELAY_1S) {
      delay(1000);
    } else {
      sendTap(seq[i]);
    }
  }
}

void refreshIdlePixels() {
  for (uint8_t i = 0; i < 4; i++) {
    if (i == KEY4 && waitingForLayer2) continue;
    neokey.pixels.setPixelColor(i, COLOR_OFF);
  }
  neokey.pixels.show();
}

void setStandbyLevel(uint8_t level) {
  standbyLevel = level;
  refreshIdlePixels();
}

void advanceStandbyLevel() {
  standbyLevelIndex = (standbyLevelIndex + 1) % 3;
  setStandbyLevel(STANDBY_LEVELS[standbyLevelIndex]);
}

void enterWaiting() {
  waitingForLayer2 = true;
  waitStartMs = millis();
  neokey.pixels.setPixelColor(KEY4, COLOR_PURPLE);
  neokey.pixels.show();
}

void cancelWaiting() {
  waitingForLayer2 = false;
  neokey.pixels.setPixelColor(KEY4, COLOR_OFF);
  neokey.pixels.show();
}

void clearLayer2State() {
  waitingForLayer2 = false;
}

void fireMacroWithColor(uint8_t keyIdx, uint32_t color,
                         const uint8_t* seq, uint8_t len,
                         bool clearKey4 = false) {
  if (clearKey4) {
    neokey.pixels.setPixelColor(KEY4, COLOR_OFF);
  }
  neokey.pixels.setPixelColor(keyIdx, color);
  neokey.pixels.show();
  playMacro(seq, len);
  neokey.pixels.setPixelColor(keyIdx, COLOR_OFF);
  neokey.pixels.show();
}

void fireKey1Layer2() {
  neokey.pixels.setPixelColor(KEY4, COLOR_OFF);
  neokey.pixels.setPixelColor(KEY1, COLOR_ORANGE);
  neokey.pixels.show();

  sendTap(TAP_MOD_LCTRL);
  sendTap(TAP_MOD_LCTRL);
  sendComboTap(KEYBOARD_MODIFIER_LEFTALT, HID_KEY_G);

  neokey.pixels.setPixelColor(KEY1, COLOR_OFF);
  neokey.pixels.show();
}

void handlePress(uint8_t idx) {
  bool useLayer2 = waitingForLayer2;
  if (waitingForLayer2) {
    clearLayer2State();
  }

  if (useLayer2) {
    if (idx == KEY1) {
      fireKey1Layer2();
    } else if (idx == KEY2) {
      fireMacroWithColor(KEY2, COLOR_LIME, macro_key2_L2, MACRO_LEN(macro_key2_L2), true);
    } else if (idx == KEY3) {
      fireMacroWithColor(KEY3, COLOR_LIGHT_BLUE, macro_key3_L2, MACRO_LEN(macro_key3_L2), true);
    }
  } else {
    if (idx == KEY1) {
      fireMacroWithColor(KEY1, COLOR_RED, macro_key1_L1, MACRO_LEN(macro_key1_L1));
    } else if (idx == KEY2) {
      fireMacroWithColor(KEY2, COLOR_GREEN, macro_key2_L1, MACRO_LEN(macro_key2_L1));
    } else if (idx == KEY3) {
      fireMacroWithColor(KEY3, COLOR_BLUE, macro_key3_L1, MACRO_LEN(macro_key3_L1));
    }
  }
}

void handleKey4Press() {
  key4Down = true;
  key4HoldFired = false;
  key4PressStart = millis();
}

void handleKey4Release() {
  key4Down = false;

  if (key4HoldFired) {
    key4HoldFired = false;
    return;
  }

  if (waitingForLayer2) {
    cancelWaiting();
  } else {
    enterWaiting();
  }
}

void checkKey4Hold() {
  if (!key4Down || key4HoldFired) return;

  if (millis() - key4PressStart >= HOLD_MS) {
    key4HoldFired = true;
    advanceStandbyLevel();
  }
}

void loop() {
#ifdef TINYUSB_NEED_POLLING_TASK
  TinyUSBDevice.task();
#endif

  delay(2);
  if (!usb_hid.ready()) return;

  uint8_t buttons = neokey.read();

  for (uint8_t i = 0; i < 4; i++) {
    bool raw_pressed = (buttons & (1 << i)) != 0;

    if (raw_pressed != last_raw_state[i]) {
      last_raw_state[i] = raw_pressed;
      last_change_ms[i] = millis();
    }

    if ((millis() - last_change_ms[i]) >= DEBOUNCE_MS &&
        raw_pressed != last_stable_state[i]) {
      last_stable_state[i] = raw_pressed;

      if (i == KEY4) {
        if (last_stable_state[i]) {
          handleKey4Press();
        } else {
          handleKey4Release();
        }
      } else if (last_stable_state[i]) {
        // Rising edge: key1/2/3 just pressed
        handlePress(i);
      }
    }
  }

  checkKey4Hold();

  if (waitingForLayer2 && (millis() - waitStartMs) >= LAYER2_TIMEOUT_MS) {
    cancelWaiting();
  }
}

That’s cool and all, but can you give me the tldr “get it up and running”?

  1. Purchase the items in the [DIY GUIDE] “Parts List” that includes the NeoKey (no thumbstick, that was just for testing).
  2. Assemble – snap into place, no soldering.
  3. Install Arduino IDE
  4. Setup Arduino to work with the Trinkey (install any necessary libraries + allow dependants) and make a new project:
    • (Board Manager) AMD SAMD Boards
    • (Library Manager) Adafruit seesaw
    • (Library Manager) Adafruit TinyUSB Library
    • (Library Manager) Adafruit NeoPixel
  5. Set Tools → USB Stack → TinyUSB
  6. Sst Tools → Board → Adafruit TRRS Trinkey M0 (SAMD21)
  7. Copy & paste the sketch above.
  8. Plug the trinkey into one of the blue ports on the KVM (or straight into your PC).
  9. Press the physical button on the trinkey twice to put it in programming mode.
  10. Compile in Arduino IDE.
  11. Unplug the Trinkey from the port it’s in, and plug it into a HID port on the KVM.

Done!

Resources

2 Likes

Done (I think)! With the actual “getting it to work” thing.

I want to make a video showcasing the macropad and what the hotkeys do exactly like audio follows or USB follows.

Uploaded 3D printed housing