Controlling a Keyboard from Your PC with QMK Raw HID
Table of Contents
Background
QMK Firmware (hereafter FW) exposes APIs that make many features easy to use. Among the lesser-known but genuinely useful functions I rely on daily is Raw HID. It lets the PC and keyboard communicate bidirectionally over USB.
It is indispensable for certain developers, yet many people who use FW features never think about it, so I decided to write up a quick overview. Other FWs likely have similar functions; if you know of them, please tell me. I would also be overjoyed to hear “here’s another way to use it!” comments.
What can it do?
First, some concrete use cases.
A very intuitive example is adjusting parameters from a web browser. Trackballs benefit the most: users often want to tweak angle or pointer speed, and it needs fine tuning. Being able to set this graphically, quickly, and intuitively is great.
As a reference, I built an app that uses a browser-specific adjustment method. The user rolls the trackball straight up from the center of a circle and clicks near the edge where it touches. I take the current setting and the angular error from straight up to compute the desired angle, then store it on the keyboard.

Trackball tuning UI; planned for Cue2Keys 2
For magnetic switches, you can clearly set the actuation and release points. It is also handy for values that are awkward to set with custom keycodes, like LED pattern colors. The more parameters and complexity a keyboard has, the more value you get.
It is also useful for demo booths. For example, if you want to showcase multiple lighting patterns, you can render buttons in a web app so the display changes when each is pressed. Preparing this is more work than telling visitors to press custom keycodes, but you do not need to explain “press this key to do that,” so they can explore more freely. Up to here, these are “(persistent) parameter-setting” use cases.
Another use case is inspecting keyboard state. You can display the current FW version or list connected modules. On the PC side it is easy to judge whether FW is up-to-date and show a link, giving users a clear path to update. Even if the keyboard itself lacks a big display, you can surface non–real-time info via the browser. This is the “state reference” category.
It gets harder, but you could probably change layers automatically depending on the active app (I have not tried). Building a native OS app is work, but mice from makers like Logitech often do this, so maybe keyboards will someday too?[1] That would be a “dynamic state change” example.
A niche feature?
Despite sounding handy, explicit use cases are very rare.
Let’s check how many firmwares implement the raw_hid_receive or raw_hid_send functions listed in the QMK docs. In the QMK repository there are only two firmwares merged that do. With 1,090 firmwares registered as of this post, that is a 0.18% usage rate.
Actually used everywhere
It looks lonely, but Raw HID is heavily used under the hood—because VIA uses it internally.
VIA lets you set keymaps from the browser and writes them to the keyboard’s flash, so it is literally PC ↔ keyboard communication. The code shows this at quantum/via.c#L290.
VIA also offers Custom UI, a user-defined parameter UI. You can map a parameter to basic form elements like toggle buttons, ranges, or dropdowns, and there is even a color picker—perfect for browser-based setup. I found four keyboards in upstream QMK and 14 in the-via/qmk_userspace_via using it, so the number has grown a bit.
On another FW, sekigon provides Custom UI for Vial (sekigon-gonnoc/via-custom-ui-for-vial). It is used to tune trackballs on supported boards and to reduce wireless traffic—practical, real-world use (reference article).
So Raw HID is a behind-the-scenes workhorse.
How to add it
Let’s look at implementation. There are many ways, but I assume the following:
- Keep VIA enabled and coexist with it.
- Stick (as much as possible) to the VIA Custom UI protocol.
- Provide a web front end.
With this setup, you retain VIA’s existing features and add your own. Let’s sprinkle on only what you need.
Points to note first
The QMK Raw HID docs warn:
Because the HID specification does not support variable length reports, all reports in both directions must be exactly RAW_EPSIZE (currently 32) bytes long, regardless of actual payload length. However, variable length payloads can potentially be implemented on top of this by creating your own data structure that may span multiple reports.
https://docs.qmk.fm/features/rawhid#sending-data-to-the-keyboard
In short, each transfer is always 32 bytes. The implementation below assumes this.
Also, the docs say to set RAW_ENABLE = yes to use Raw HID, but you can omit it when VIA is enabled.
QMK-side implementation
On QMK, first enable VIA by adding VIA_ENABLE = yes to rules.mk.
VIA_ENABLE = yes
Proceed while referencing the VIA Custom UI docs. I will pick out the important bits.
The core is the void via_custom_value_command_kb(uint8_t *data, uint8_t length) function. When VIA decides “this is for Custom UI,” it calls this. Each keyboard overrides it to add the needed behavior.
Specifically, if the command ID in the first byte of the 32-byte packet is 0x07 (id_custom_set_value), 0x08 (id_custom_get_value), or 0x09 (id_custom_save), control is delegated here. The next byte is the channel ID. You can choose it freely, but VIA uses some IDs, so 0 is the safest choice; I will use 0 below.
With that in place, you implement your logic. You can structure the incoming data however you like; I use the struct below. VIA only defines the first two bytes. My header consumes six bytes—arguably too many—but I include extra for future expansion and debugging. Everything is held as pointers; I will explain why later.
typedef struct {
uint8_t *command_id;
uint8_t *channel_id;
uint8_t *protocol_version;
uint8_t *seq;
uint8_t *value_id;
uint8_t *data_length;
uint8_t *payload;
} pkt_t;
pkt_t load_pkt(uint8_t *data) {
pkt_t pkt;
pkt.command_id = &data[0];
pkt.channel_id = &data[1];
pkt.protocol_version = &data[2];
pkt.seq = &data[3];
pkt.value_id = &data[4];
pkt.data_length = &data[5];
pkt.payload = &data[6];
return pkt;
}
Here is the main via_custom_value_command_kb implementation. It branches on the defined values and hands off to my own set_param, get_param, and save_param functions.
void via_custom_value_command_kb(uint8_t *data, uint8_t length) {
pkt_t pkt = load_pkt(data);
if (*pkt.channel_id == id_custom_channel) {
switch (*pkt.command_id) {
case id_custom_set_value: {
set_param(&pkt);
break;
}
case id_custom_get_value: {
get_param(&pkt);
break;
}
case id_custom_save: {
save_param();
break;
}
default: {
// Unhandled message.
*pkt.command_id = id_unhandled;
break;
}
}
return;
}
// Return the unhandled state
*pkt.command_id = id_unhandled;
// DO NOT call raw_hid_send(data,length) here, let caller do this
}
Saving parameters
First, save_param. As the name suggests, it writes the settings to the keyboard’s EEPROM (flash memory).
void save_param(void) {
eeconfig_update_kb_datablock(&kb_config.raw, 0, sizeof(kb_config));
}
Simple, but there is a design choice here. As the QMK docs note, you should minimize EEPROM writes. Flash has a limited write count[2]; if you write on every keypress, it will wear out quickly. Ideally, the FW would throttle writes with a timer. I skipped that for now because doing it in QMK is tedious and I am building the front end myself anyway.
Also, even though the code is simple, eeconfig_update_kb_datablock is not mentioned in the QMK docs, and few keyboards need it, so it might be unfamiliar. Feel free to dig in.
Reading parameters
Next, get_param, which fetches values from the keyboard. You can design it however you like; the pseudocode below shows my approach.
void get_param(pkt_t *pkt) {
uint8_t value_id = *pkt->value_id;
uint8_t *data = pkt->payload;
uint8_t *data_length = pkt->data_length;
if (value_id < VCVID_START) {
// parameter value is always 32 bits
uint32_t value = 0;
*data_length = 4; // max length
switch (value_id) {
case 1:
value = kb_config.mouse_layer_on;
break;
case 2:
value = kb_config.mouse_layer_off_delay_ms;
break;
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
for (uint8_t i = 0; i < (*data_length); i++) {
data[i] = (uint8_t)(value >> (i * 8));
}
return;
} else {
switch (value_id) {
case VCVID_SPECIAL_CASE:
<special handling>
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
}
return;
}
The incoming data contains an ID (value_id) that indicates which parameter to handle, so I pull that out first. Next, I fetch the actual value. Most parameters fit in 4 bytes, so if value_id is smaller than a defined value like VCVID_START (e.g., 100), I assume the value is always 4 bytes. Anything larger is special handling that can exceed 4 bytes; more on that later.
Where do we return the value? By writing back into the packet we received. VIA will send back the same 32-byte region, so you only modify the needed fields and the rest get echoed. That is why the struct fields were pointers.
Setting parameters
Finally, set_param. Values sent to the keyboard are assumed to fit within 4 bytes.
static inline uint32_t clamp_u32(uint32_t v, uint32_t max) {
return v > max ? max : v;
}
void set_param(pkt_t *pkt) {
// the value is always 32 bits
uint32_t value = pkt->payload[3] << 24 | pkt->payload[2] << 16 | pkt->payload[1] << 8 | pkt->payload[0];
uint8_t value_id = *pkt->value_id;
switch (value_id) {
case 1:
kb_config.mouse_layer_on = clamp_u32(value, 1);
break;
case 2:
kb_config.mouse_layer_off_delay_ms = clamp_u32(value, 63);
break;
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
apply_set_param_side_effect(value_id);
}
As you see, I clamp with clamp_u32 to prevent bad values. I also call my own apply_set_param_side_effect at the end. Anything that must take effect immediately—like handing values to the trackball driver—lives there for clarity.
void apply_set_param_side_effect(uint8_t value_id) {
# ifdef POINTING_DEVICE_ENABLE
switch (value_id) {
case 1:
set_auto_mouse_enable(kb_config.mouse_layer_on);
break;
case 2:
set_auto_mouse_timeout(calc_auto_mouse_timeout_by_kbconfig(kb_config.mouse_layer_off_delay_ms));
break;
...
default:
break;
}
# endif
}
As noted earlier, EEPROM writes happen elsewhere, so I ignore them here.
Web front-end implementation
Next, let’s build the browser app. There are many options; I will assume:
- Use TypeScript.
- Any web framework is fine.
- Implement with WebHID API instead of node-hid (for explanation’s sake).
If you have never built a web app, consult other docs as needed; details depend on your framework and libraries. Hopefully the flow makes sense.
Usually a static site is enough, so plenty of services host this for free—great for hobby keyboards.
Connecting to the keyboard
Here is connection logic. On a VIA-enabled keyboard, running code like this pops up the device chooser in the browser.
export async function connect(): Promise<HIDDevice | undefined> {
const list = await navigator.hid.requestDevice({
filters: [
{
vendorId: VENDOR_ID,
productId: PRODUCT_ID,
usagePage: USAGE_PAGE,
usage: USAGE_ID,
},
],
});
if (!list.length) return;
const dev: HIDDevice = list[0]!;
await dev.open();
return dev;
}

Device chooser
If every device showed up, you would not know which is yours, so I filter the target. Set VENDOR_ID and PRODUCT_ID to match your keyboard, and set USAGE_PAGE and USAGE_ID per the QMK docs. Once you get the device instance, use it for everything that follows.
Sending data to the keyboard
Now the core send/receive logic.
export async function send(
dev: HIDDevice,
apiCommand: APICommand,
valueID: CustomValueID,
payload: Uint8Array,
): Promise<SendData> {
if (!dev) throw new Error("not connected");
const sd: SendData = new SendData(apiCommand, valueID, payload);
const out = sd.toUint8Array();
await dev.sendReport(REPORT_ID, out);
const response = new Promise<SendData>((resolve, reject) => {
const onInput = (e: HIDInputReportEvent) => {
const u8 = new Uint8Array(e.data.buffer);
if (sd.verifyResponse(u8)) {
const typedData = SendData.loadFromUint8Array(u8);
clearTimeout(to);
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
resolve(typedData);
}
};
dev.addEventListener("inputreport", onInput);
const to = setTimeout(() => {
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
reject(new Error("timeout"));
}, 2000);
});
return response;
}
The tough part of HID is converting between raw bytes and your struct; use a library to make it easier. As shown earlier, VIA echoes back what we send, so after sending I also listen for replies. I handle the inputreport event and set a 2-second timeout. To confirm the reply matches what we sent, verifyResponse checks the unchanging fields; if valid, I pack it into typedData and return.
Once you have send/receive working, bind it to your form to implement the desired features. Have the front end issue the three VIA command IDs as needed. To reduce write frequency, queue the save command (id_custom_save via send) if it is called multiple times within a second, for example.
Advanced: multipart
Finally, an advanced pattern for larger transfers.
Each transfer is capped at 32 bytes (effectively even less after headers). How do you send bigger or variable-length data—like per-key settings or lists of connected modules—from the keyboard?
Here is a simple approach: add two bytes to the header for part number and total parts. That lets you split the data. It only supports up to 256 parts, but that suffices for explanation. Also, because VIA’s standard implementation only sends 32 bytes, use raw_hid_send here.
To keep this short, here is the key snippet. On QMK, branch inside get_param and send multiple times directly with raw_hid_send.
...
for (uint8_t idx = 0; idx < chunks + 1; idx++) {
multipart_t resp = {0};
uint16_t off = (uint16_t)idx * PAYLOAD;
uint16_t rem = (uint16_t)(total_len - off);
uint8_t take = (uint8_t)(rem > PAYLOAD ? PAYLOAD : rem);
// echo header fields
resp.command_id = *pkt->command_id;
resp.channel_id = *pkt->channel_id;
resp.protocol_version = *pkt->protocol_version;
resp.seq = *pkt->seq;
resp.value_id = *pkt->value_id;
resp.data_length = (uint8_t)(META + take);
resp.part = idx;
resp.total_parts = chunks;
memcpy(&resp.data[0], json_buf + off, take);
// always send 32 bytes
uint8_t out[32] = {0};
memcpy(&out[0], &resp, 32);
raw_hid_send(out, 32);
}
...
PAYLOAD is how many bytes you can send per packet: 32 minus the header. You simply split the data and call raw_hid_send repeatedly.
On the front end, extend the receive logic: once all parts arrive, concatenate them in order to rebuild the byte array. This rough sketch shows the idea.
const response = SendData.loadFromUint8Array(u8);
const chunk = MultiPartData.loadFromUint8Array(
response.data.slice(0, response.dataLength),
);
if (!parts.length)
for (let i = 0; i < chunk.totalParts + 1; i++) parts.push(null);
parts[chunk.part] = chunk.data;
// done?
if (parts.every((p) => p !== null)) {
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
clearTimeout(to);
const totalLen = parts.reduce((acc, cur) => acc + (cur ? cur.length : 0), 0);
const buf = new Uint8Array(totalLen);
let off = 0;
for (const chunk of parts) {
const rem = Math.min(chunk.length, totalLen - off);
if (rem <= 0) break;
buf.set(chunk.slice(0, rem), off);
off += rem;
}
resolve(buf);
}
Now you can receive long data too. The use cases are limited, but it is handy to remember when you are stuck.
Takeaways
I introduced the basics of Raw HID. This was a high-level overview, but I hope it gives you a feel for how to use and implement it.
You do need a front-end app, so it may feel a bit involved. On the other hand, shipping an app alongside the keyboard can make it clearer to use and give it distinctive character, which is fun. With a web app, long-term hosting is relatively low cost.
It also helps demo features at events. If you know other interesting uses, please drop a comment.
I wrote this on the in-development Cue2Keys 2.
A dedicated web app using Raw HID is coming too!
Mice have few buttons, so they cannot spare buttons for layer switching—that is likely why. Still, on a keyboard, if you jump between apps a lot, it could help. Not needing to switch explicitly feels gentle on the brain. ↩︎
For reference, the
W25Q128JVS(12 MB) used on Cue2Keys is rated for at least 100k writes—pretty generous. Adjust the interval based on the HW and how often you tweak settings. ↩︎