Human-Interfacing Devices: HID Over I2C

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.

This capacitive touchscreen controller is not I2C-HID, even though it is I2C. By [Raymond Spekking], CC-BY-SA 4.0
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.

For some reason, Microsoft decided to distribute this spec as a .docx file, something that I immediately abused as a way of stress relief

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:
    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(l, d)
            d = d[3:]
            print(l, len(d), d)
  , d)
    except OSError:
    # touchpad unplugged? retry in a bit

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.

11 thoughts on “Human-Interfacing Devices: HID Over I2C

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

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

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

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

  3. 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:

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

Leave a Reply

Please be kind and respectful to help make the comments section excellent. (Comment Policy)

This site uses Akismet to reduce spam. Learn how your comment data is processed.