feat(q5_max): implement bi-directional Raw HID protocol

Add hid_protocol.h defining a shared 32-byte packet structure for the
qmk-host bridge application (command IDs 0x40-0x7E, clear of VIA's
range). Implement via_command_kb() in keymap.c to intercept incoming
packets: LAYER_SYNC applies a new active layer, VOLUME and BRIGHTNESS
store host-reported values for future RGB indicators, and ACTIVE_APP
is stubbed for a later commit. Layer state changes are broadcast to the
host via raw_hid_send() from layer_state_set_user(), guarded by
g_hid_recv_active to prevent echo loops when the change itself
originated from HID.
This commit is contained in:
2026-04-10 14:41:58 -04:00
parent 9b2cd0cb32
commit 6fd5179c55
2 changed files with 207 additions and 10 deletions
@@ -0,0 +1,86 @@
// Copyright 2024 rootiest
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
/*
* qmk-host Shared HID Protocol
* =============================
* Bi-directional Raw HID protocol shared between QMK firmware and the
* qmk-host Rust application. All values MUST match between both sides.
*
* Physical layer (QMK defaults, NOT overridden):
* Usage Page : 0xFF60
* Usage ID : 0x0061
* Packet size: RAW_EPSIZE (32 bytes)
*
* Packet layout:
* Byte 0 : Command ID
* Byte 1 : Source Device ID
* Byte 2 : Flags
* Bytes 3-31: Payload (29 bytes)
*
* Command IDs use the range 0x40-0x7E to avoid any overlap with VIA's
* protocol (0x01-0x3F) or the reserved unhandled sentinel (0xFF).
*/
// ---------------------------------------------------------------------------
// Command IDs
// ---------------------------------------------------------------------------
#define HID_CMD_LAYER_SYNC 0x40u // Layer state sync (keyboard ↔ host ↔ keyboard)
#define HID_CMD_VOLUME 0x41u // System volume level (host → keyboard)
#define HID_CMD_BRIGHTNESS 0x42u // Screen brightness level (host → keyboard)
#define HID_CMD_ACTIVE_APP 0x43u // Active window/app name (reserved — future)
#define HID_CMD_ACK 0x7Eu // Generic acknowledgement
// ---------------------------------------------------------------------------
// Source Device IDs
// ---------------------------------------------------------------------------
#define HID_DEV_HOST 0x00u // qmk-host Rust application
#define HID_DEV_Q5MAX 0x01u // Keychron Q5 Max (this firmware)
#define HID_DEV_NUMPAD 0x02u // Numpad (future second keyboard)
// ---------------------------------------------------------------------------
// Packet Flags (Byte 2)
// ---------------------------------------------------------------------------
#define HID_FLAG_QUERY 0x01u // Request: send back current state, no change
#define HID_FLAG_RESPONSE 0x02u // Response: this is a reply to a query
// ---------------------------------------------------------------------------
// Byte offsets within the 32-byte packet
// ---------------------------------------------------------------------------
#define HID_OFF_CMD 0u // Command ID
#define HID_OFF_SRC 1u // Source Device ID
#define HID_OFF_FLAGS 2u // Flags
#define HID_OFF_PAYLOAD 3u // Start of payload
// ---------------------------------------------------------------------------
// HID_CMD_LAYER_SYNC payload (from byte HID_OFF_PAYLOAD)
// [0] Active layer index (0 = BASE … 5 = KEEB_CTL)
// [1] Locked layers bitmask (bit N = layer N is locked)
// ---------------------------------------------------------------------------
#define HID_LAYER_OFF_ACTIVE 0u
#define HID_LAYER_OFF_LOCKED 1u
// ---------------------------------------------------------------------------
// HID_CMD_VOLUME payload
// [0] Volume level, 0-100 (%)
// ---------------------------------------------------------------------------
#define HID_VOLUME_OFF_LEVEL 0u
// ---------------------------------------------------------------------------
// HID_CMD_BRIGHTNESS payload
// [0] Brightness level, 0-100 (%)
// ---------------------------------------------------------------------------
#define HID_BRITE_OFF_LEVEL 0u
// ---------------------------------------------------------------------------
// HID_CMD_ACTIVE_APP payload (reserved — no firmware action yet)
// [0..27] Null-terminated UTF-8 application name (max 28 bytes incl. NUL)
// ---------------------------------------------------------------------------
#define HID_APP_NAME_MAX 28u
// ---------------------------------------------------------------------------
// Convenience: first byte of payload as an absolute packet index
// ---------------------------------------------------------------------------
#define HID_PAYLOAD(offset) ((uint8_t)((HID_OFF_PAYLOAD) + (offset)))
@@ -17,6 +17,8 @@
#include QMK_KEYBOARD_H
#include "keychron_common.h"
#include "chord_unicode.h"
#include "raw_hid.h"
#include "hid_protocol.h"
// Tap Dance declarations
enum {
@@ -37,6 +39,16 @@ enum custom_keycodes {
CAPS_MOD, // Tap=ESC, hold=Ctrl, Shift=CapsLock, Alt=CapsWord, GUI=Autocorrect
};
// Declare layers early so the HID functions below can reference KEEB_CTL.
enum layers {
BASE,
FN1,
FN2,
FN3,
FN4,
KEEB_CTL,
};
// Alt-Tab cycling state
static bool alt_tab_active = false;
static uint16_t alt_tab_timer = 0;
@@ -46,20 +58,107 @@ static uint16_t alt_tab_timer = 0;
// momentary (TT/MO) keys are released.
static layer_state_t locked_layers = 0;
// ---------------------------------------------------------------------------
// Raw HID state
// ---------------------------------------------------------------------------
// Anti-loop guard: set to true while applying a layer change that arrived
// over HID so that layer_state_set_user() does not echo it back.
static bool g_hid_recv_active = false;
// Last highest-layer value we sent via HID. 0xFF = never sent.
// Avoids redundant sends when layer_state_set_user is called multiple times
// with the same effective top layer.
static uint8_t g_last_sent_layer = 0xFF;
// Latest host-reported values (stored for future RGB indicator use).
static uint8_t g_hid_volume = 0;
static uint8_t g_hid_brightness = 0;
// Send the current layer state to the host / bridge application.
static void hid_send_layer_sync(uint8_t layer, uint8_t locked_mask) {
uint8_t data[RAW_EPSIZE] = {0};
data[HID_OFF_CMD] = HID_CMD_LAYER_SYNC;
data[HID_OFF_SRC] = HID_DEV_Q5MAX;
data[HID_OFF_FLAGS] = 0;
data[HID_PAYLOAD(HID_LAYER_OFF_ACTIVE)] = layer;
data[HID_PAYLOAD(HID_LAYER_OFF_LOCKED)] = locked_mask;
raw_hid_send(data, RAW_EPSIZE);
}
// Handle a Raw HID packet for our custom command range (0x40-0x7E).
// Called from via_command_kb(); must call raw_hid_send() for any reply.
bool via_command_kb(uint8_t *data, uint8_t length) {
uint8_t cmd = data[HID_OFF_CMD];
// Only intercept our custom command range; let VIA handle everything else.
if (cmd < 0x40u || cmd > 0x7Eu) {
return false;
}
uint8_t flags = data[HID_OFF_FLAGS];
switch (cmd) {
case HID_CMD_LAYER_SYNC: {
if (flags & HID_FLAG_QUERY) {
// Host requests current state — reply without changing anything.
uint8_t resp[RAW_EPSIZE] = {0};
resp[HID_OFF_CMD] = HID_CMD_LAYER_SYNC;
resp[HID_OFF_SRC] = HID_DEV_Q5MAX;
resp[HID_OFF_FLAGS] = HID_FLAG_RESPONSE;
resp[HID_PAYLOAD(HID_LAYER_OFF_ACTIVE)] = get_highest_layer(layer_state);
resp[HID_PAYLOAD(HID_LAYER_OFF_LOCKED)] = (uint8_t)locked_layers;
raw_hid_send(resp, RAW_EPSIZE);
} else {
// Host or peer keyboard is pushing a new active layer.
uint8_t new_layer = data[HID_PAYLOAD(HID_LAYER_OFF_ACTIVE)];
uint8_t new_locked = data[HID_PAYLOAD(HID_LAYER_OFF_LOCKED)];
if (new_layer <= KEEB_CTL) {
// Set the guard BEFORE calling layer_move() so that the
// resulting layer_state_set_user() call does not echo the
// change back to the host, preventing an infinite loop.
g_hid_recv_active = true;
locked_layers = new_locked;
layer_move(new_layer);
g_hid_recv_active = false;
}
}
break;
}
case HID_CMD_VOLUME: {
// Store the host-reported volume (0-100).
g_hid_volume = data[HID_PAYLOAD(HID_VOLUME_OFF_LEVEL)];
// TODO: drive an RGB volume-bar indicator here in a future commit.
break;
}
case HID_CMD_BRIGHTNESS: {
// Store the host-reported screen brightness (0-100).
g_hid_brightness = data[HID_PAYLOAD(HID_BRITE_OFF_LEVEL)];
// TODO: drive an RGB brightness indicator here in a future commit.
break;
}
case HID_CMD_ACTIVE_APP: {
// Reserved — no action yet. Payload is a null-terminated UTF-8
// application name (up to HID_APP_NAME_MAX bytes).
// TODO: implement active-app handling once the host side is ready.
break;
}
default:
break;
}
return true; // packet was fully handled by us
}
// CAPS_MOD state: tap=ESC, hold=Ctrl, Shift+tap=CapsLock, Alt+tap=CapsWord, GUI+tap=Autocorrect
static bool caps_mod_held = false;
static bool caps_mod_ctrl_registered = false;
static uint16_t caps_mod_timer = 0;
enum layers {
BASE,
FN1,
FN2,
FN3,
FN4,
KEEB_CTL,
};
// clang-format off
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
[BASE] = LAYOUT_ansi_101(
@@ -134,8 +233,20 @@ combo_t key_combos[] = {
};
// Re-assert locked layers whenever QMK modifies layer state (e.g. TT release).
// Also notifies the host application of the new active layer via Raw HID,
// unless the change was itself triggered by an incoming HID packet (anti-loop).
layer_state_t layer_state_set_user(layer_state_t state) {
return state | locked_layers;
state |= locked_layers;
if (!g_hid_recv_active) {
uint8_t top = get_highest_layer(state);
if (top != g_last_sent_layer) {
g_last_sent_layer = top;
hid_send_layer_sync(top, (uint8_t)locked_layers);
}
}
return state;
}
void keyboard_post_init_user(void) {