In the previous two HID articles, we talked about stealing HID descriptors, learned about a number of cool tools you can use for HID hacking on Linux, and created a touchscreen device. This time, let’s talk about an underappreciated HID standard, but one that you might be using right now as you’re reading this article – I2C-HID, or HID over I2C.
HID as a protocol can be tunneled over many different channels. If you’ve used a Bluetooth keyboard, for instance, you’ve used tunneled HID. For about ten years now, I2C-HID has been heavily present in laptop space, it was initially used in touchpads, later in touchscreens, and now also in sensor hubs. Yes, you can expose sensor data over HID, and if you have a clamshell (foldable) laptop, that’s how the rotation-determining accelerometer exposes its data to your OS.
Not every I2C-connected input device is I2C-HID. For instance, if you’ve seen older tablets with I2C-connected touchscreens, don’t get your hopes up, as they likely don’t use HID – it’s just a complex-ish I2C device, with enough proprietary registers and commands to drive you crazy even if your logic analysis skills are on point. I2C-HID is nowhere near that, and it’s also way better than PS/2 we used before – an x86-only interface with limited capabilities, already almost extinct from even x86 boards, and further threatened in this increasingly RISCy world. I2C-HID is low-power, especially compared to USB, as capable as HID goes, compatible with existing HID software, and ubiquitous enough that you surely already have an I2C port available on your SBC.
In modern world of input devices, I2C-HID is spreading, and the coolest thing is that it’s standardized. The standardization means a lot of great things for us hackers. For one, unlike all of those I2C touchscreen controllers, HID-I2C devices are easier to reuse; as much as information on them might be lacking at the moment, that’s what we’re combating right now as we speak! If you are using a recent laptop, the touchpad is most likely I2C-HID. Today, let’s take a look at converting one of those touchpads to USB HID.
A Hackable Platform
Two years ago, I developed a Framework laptop input cover controller board. Back then, I knew some things about I2C-HID, but not too much, and it kinda intimidated me. Still, I wired up the I2C pins to an I2C port on an RP2040, wired up the INT pin to a GPIO, successfully detected an I2C device on those I2C pins with a single line of MicroPython code, and left sitting on my desk out of dread over converting touchpad data into mouse events – as it turns out, it was way simpler than I thought.
There’s a specification from Microsoft, and it might be your first jumping point. I tried reading the specification, but I didn’t understand HID at the time either, so that didn’t help much. Looking back, the specification is pretty hard to read, regardless. Here’s the deal in the real world.
If you want to get the HID descriptor from an I2C-HID device, you only need to read a block of data from its registers. Receiving reports (HID event packets) is simple, too. When the INT pin goes low, read a block of data from the device – you will receive a HID report. If there’s an RST pin, you will want to bring it down upon bootup for a few hundred milliseconds to reset the device, and you can use it in case your I2C-HID device malfunctions, too.
Now, there are malfunctions, and there definitely will be quirks. Since HID is ubiquitous, there are myriad ways for manufacturers to abuse it. For instance, touchpads are so ubiquitous that Chrome OS has entire layers dealing with their quirks. But here we are, and I have an I2C device connected to an RP2040, previous MicroPython I2C work in hand, some LA captures between the touchpad and the original system stashed away, and I’m ready to send it all commands it needs.
Poking And Probing
To read the descriptor, you can read a block from register 0x20, where the first four bytes define the descriptor version and the descriptor length – counting these four bytes in. When we put this descriptor into the decoder, we will get something like this:
[...]
0x05, 0x0D, // Usage Page (Digitizer)
0x09, 0x05, // Usage (Touch Pad)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x05, 0x0D, // Usage Page (Digitizer)
0x09, 0x22, // Usage (Finger)
0xA1, 0x02, // Collection (Logical)
0x09, 0x47, // Usage (Confidence)
0x09, 0x42, // Usage (Tip Switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
[...]
That is a HID descriptor for a touchpad alright! Save this descriptor somewhere – while getting it dynamically is tempting, hardcoding it into your firmware also might be a viable decision, depending on which kind of firmware you’ll be adding I2C-HID support into, and, you’ll really want to have it handy as a reference. Put this descriptor into our favourite decoder website, and off we go! Oh, and if you can’t extract the descriptor from the touchpad for whatever reason, you can get it from inside a running OS like I’ve done in the last article – that’s what I ended up doing, because I couldn’t make MicroPython fetch the descriptor properly.
Take a look at the report IDs – they can be helpful later. All reports coming from the touchpad will have their report ID attached, and it’s good to know just which kinds of events you can actually expect. Also, here’s a challenge – try to spot the reports used for BIOS “simple mouse” functionality, firmware update, touchpad calibration, and any proprietary features!
Now, all that’s left is getting the reports. This is simple too – you don’t even need to read a block from a register, just a block of data from the touchpad. First, you read a single byte, which tells you how many more bytes you need to read to get the actual packet. Then you read a byte once INT is asserted (set low). That means the touchpad has data for you. If your INT doesn’t work for some reason, as it was on my board, you could continuously poll the touchpad in a loop instead, reading a single byte each time, and reading out a full packet when the first byte isn’t 0x00
. Then, it’s the usual deal – first byte is the report ID, and all other bytes are the actual report contents. For I2C code of the kind that our last article uses, reading a report works like this:
while True: try: l = i2c.readfrom(0x2c, 1)[0] if l: d = i2c.readfrom(0x2c, l) if d[2] != 0x01: # only forward packets with a specific report ID, discard all others print("WARNING") print(l, d) print("WARNING") else: d = d[3:] print(l, len(d), d) usb_hid.report(usb_hid.MOUSE_ABS, d) except OSError: # touchpad unplugged? retry in a bit sleep(0.01)
Now, touch the touchpad, and see. Got a report? Wonderful! Haven’t received anything yet? There are a few things to check. First, your touchpad might require a TP_EN pin to be asserted low or high. Also, if your touchpad has a TP_RST pin, you might need to pull it low on startup for a couple hundred milliseconds. Other than that, if your touchpad is from a reasonably popular laptop, see if there’s any references for its quirks in the Linux kernel, or any of the open firmwares out there.
Further Integration
Theoretically, you could write a pretty universal I2C-HID to USB-HID converter seriously easily – that would allow things like USB-connected touchpads on the cheap, just like some people have been doing with PS/2 in the good old days. For me, there’s an interesting question – how do you actually integrate this into a keyboard firmware? There are a few options. For instance, you could write a QMK module for dealing with any sort of I2C-HID device, that’d pass through reports from the touchpad and generate its own reports for keyboard reports. That is a viable option for most of you; for me, C++ is not my friend as much as I’d like it to be.
There’s the MicroPython option we’ve explored last article, and that’s what I’m using for forwarding at the moment. This option needs the descriptor translated into TUSB macros, which took a bit of time, but I could make it work. Soon, USB device support will be added into the new MicroPython release, which will make my translation work obsolete in all the best ways, but it isn’t merged just yet. More importantly, however, there’s no stock keyboard code I could find that’s compatible with this firmware, and as much as it could be educational, I’m not looking into writing my own keyboard scanning code.
Currently, I’m looking into a third option, KMK. A CircuitPython-based keyboard firmware, it should allow things like dynamic descriptor definitions, which lets us save a fair bit of time when iterating on descriptor hacking, especially compared to the MicroPython fork.
All of these options need you to merge keyboard and touchpad descriptors into one, which makes sense. The only caveat is the question of conflicting report IDs between the stock firmware keyboard descriptor and the stock touchpad descriptor. For fixing that, you’d want to rewrite report IDs on the fly – not that it’s complicated, just a single byte substitution, but it’s a good caveat to keep in mind! My touchpad code already does this because the library does automatic report ID insertion, but if yours doesn’t, make sure they’re changed.
Even Easier Reuse
Now, all of this was about tunneling I2C-HID-obtained HID events into USB. Are you using something like a Raspberry Pi? Good news! There’s i2c-hid support in Linux kernel, which only really wants the IRQ GPIO and the I2C address of your I2C device. Basically, all you need to do is to add a device tree fragment and some very minimal data. I don’t have a tutorial for this, but there’s some initial documentation in the kernel tree, and grepping the device tree directory for the overlay name alone should give you a wonderful start.
This article isn’t long, and that’s because of just how easy I2C-HID is to work with. Now, of course, there are quirks – just check out this file for some examples. Still, it’s nothing that you couldn’t figure out with a logic analyzer, and now you can see just how easy this is. I hope that this can help you on your hacking forays, so whenever you next see a laptop touchpad, you know just how easy they can be to wire up, no matter if you’re using a microcontroller or a Raspberry Pi.
Excellent; I’d click a like button but we don’t have that.
C++ wants to be your friend; be not afraid.
Thank you! And, that’s entirely possible – but, given the time I gotta spend on my human friends, I’m not sure we’ll ever get to know each other ;-(
Rust vies also for your friendship. One day perhaps I will get to know her…
Not sure if PS/2 is actually extinct. AMD for one uses chipsets made by a third party which undoubtedly includes one. Not sure on Intel, but the chipset has been a south bridge for 9-14 generations (depending how you count the refresh cycles).
Good to know HID tunneling is a thing, I think you can packetize it and send it over IP as well
PS/2 port on the back of my ryzen 9 based motherboard, so it’s still out there in usable machines.
Are PS/2 ports more secure than USB? I assume schools, hospitals and government would have good reason to use PS/2 ports. Or maybe manufacturers are including them based on low cost? I see DB9 all the time and it seems that only hospitals could have a use.
Well, it’s not technically extinct, but it’s way way less prominent than it ever was, if that makes sense?
As for HID tunneling over IP – yeah, I’ve recently been looking into making a thing like that on my own too! It’s really nice and Linux capabilities like emulating raw HID devices are seriously tempting.
I just upgraded my wife’s desktop to 13th gen Intel CPU. When I was reading reviews on motherboards, I was surprised to find that most desktop motherboards still have PS/2 ports. I too thought it was on its way out. I guess some serious gamers really like their PS/2 keyboards? IDK.
BTW, how is PS/2 an x86 protocol? I know it was originally developed by IBM for its IBM AT. But as far as I can tell, there’s nothing specific to x86, is there?
This article is amazing. Thanks for sharing.
Can someone add this to Zephyr and ZMK please? The likelihood of me doing it myself is low.
Also, there are indeed chips that interface USB to i2c-hid. But they require a little bit of setup IIRC.
I do really hope that people add support for I2C-HID devices into QMK/ZMK/other C/C++ stuff, because, well, best I can do is Python =D
Someday, someday, when I get some time to kill :). QMK codebase is relatively easy to work with. It’s rather well organized and not over complicated. However, I never managed to read through USB and HID specs. They are just tooooo dry.
ohhhh do you think we could chat somewhere in case I have a question on how to best implement things in QMK? I was looking to tinker with QMK some, both for the touchpad project, and also for “using I2C-HID or UART instead of USB as to-host comms” direction, and, I might need some advice on how to structure things to avoid pain.
One thing that I miss from the article (and also from the spec itself) is a good description of the whole initialization sequence. When I implemented the HID over I2C device, I informed myself from the Linux driver to see the steps that involve a host initiated reset, and also a power up command as well. Here’s a nice (if old) Nordic guide to set Linux up for it: https://github.com/NordicPlayground/nrf52-i2c-hid-demo/blob/master/Raspbian/Raspbian_HID-Over-I2C_README.md
I fully agree with the author that the HID over I2C spec itself is subpar. It screams that they first went with a 2-byte register address – single direction data transfer design (maybe they wanted it to allow co-existing with other functionality on the same I2C address?), only to later realize that the I2C restarts add a lot of overhead, and so they just threw out the restarts, but not the register addresses. Thus a single command message may carry two separate register addresses. They did remove the input register address, so input reports are directly read from the bus with the first I2C start – they forgot this unused register address in the HID descriptor though…
(Then there’s the command register, with its criminally bad layout which wastes 6 bits for nothing, but fails to provide 4 more bits to the report ID, so in very specific conditions the size of this register increases by an additional byte.)
Hmm, not sure I’ve ever seen my laptop do any sort of reset when I tapped it; perhaps I ought to check the captures more closely!
This helped me to transform my i2c laptop touchpad into a usb touchpad.
If the touchpad is working once in a laptop or Raspberry Pi with Linux, it is possible to
1) cat a generated USB report descriptor from ‘/sys/bus/hid/devices/***/report_descriptor’
2) enable hid_dev debug messages with debug ‘echo “8 4 1 7” > /proc/sys/kernel/printk’ and ‘echo ‘file i2c-hid-core.c -p’>/sys/kernel/debug/dynamic_debug/control’ to get the i2c commands used to inititialize the device with possible quirks. (rmmod and modprobe i2c_hid_acpi or i2c_hid)
3) I use a Pi Pico with mbed USBHID (simply remove everything from the USBMouse example, replace the report descriptor and forward the input reports as described in the the article after sending the commands to initialize the device)