From: Martin BTS <martinbts@gmx.net>
To: linux-bluetooth@vger.kernel.org
Cc: hadess@hadess.net, luiz.dentz@gmail.com, vi@endrift.com,
Martin BTS <martinbts@gmx.net>
Subject: [PATCH BlueZ v3 5/6] plugins/gatt-uhid: Add generic GATT-to-UHID bridge
Date: Fri, 3 Apr 2026 10:55:52 +0200 [thread overview]
Message-ID: <20260403085555.23871-6-martinbts@gmx.net> (raw)
In-Reply-To: <20260403085555.23871-1-martinbts@gmx.net>
Add a reusable bridge that creates a /dev/uhid device backed by BLE
GATT characteristics. It forwards GATT notifications as HID input
reports (UHID_INPUT2) and HID output reports (UHID_OUTPUT) back as
GATT write-without-response commands.
Report format (both directions):
byte 0: HID report ID (0x01)
byte 1-2: GATT handle, little-endian
byte 3+: payload
The bridge has no device-specific knowledge. It subscribes to all
notify-capable GATT characteristics passed by the caller and generates
a vendor-defined HID descriptor at runtime. A kernel HID driver matched
by vendor/product ID provides all protocol handling.
Input forwarding is gated on CCCD subscription completion: the bridge
suppresses UHID_INPUT2 writes until all GATT notification registrations
are confirmed, ensuring the kernel driver's output path is fully
operational before it receives any input events.
---
plugins/gatt-uhid.c | 350 +++++++++++++++++++++++++++++++++++++++
plugins/gatt-uhid.h | 56 +++++++
src/shared/gatt-client.h | 1 +
3 files changed, 407 insertions(+)
create mode 100644 plugins/gatt-uhid.c
create mode 100644 plugins/gatt-uhid.h
diff --git a/plugins/gatt-uhid.c b/plugins/gatt-uhid.c
new file mode 100644
index 000000000..2f81812fe
--- /dev/null
+++ b/plugins/gatt-uhid.c
@@ -0,0 +1,350 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Generic GATT-to-UHID bridge
+ * Bridges a BLE GATT service to the kernel HID subsystem via /dev/uhid.
+ *
+ * Report format (both directions):
+ * byte 0: HID report ID (0x01)
+ * byte 1-2: GATT handle, little-endian
+ * byte 3+: payload
+ *
+ * Input: bridge prepends [report_id][handle_lo][handle_hi] to the raw
+ * GATT notification payload.
+ * Output: bridge reads [report_id][handle_lo][handle_hi] from the HID
+ * output report and writes the remaining payload to that GATT
+ * handle via write-without-response.
+ *
+ * The HID report descriptor uses a vendor-defined usage page with a
+ * single input and output report, each sized for the 2-byte handle
+ * prefix plus the maximum payload.
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdint.h>
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <unistd.h>
+
+#include <linux/uhid.h>
+
+#include <glib.h>
+
+#include "src/log.h"
+#include "src/shared/att.h"
+#include "src/shared/gatt-client.h"
+#include "src/shared/util.h"
+
+#include "plugins/gatt-uhid.h"
+
+#define GATT_UHID_REPORT_ID 0x01
+
+/* Handle prefix overhead added to every report */
+#define GATT_UHID_HANDLE_SIZE 2
+
+/*
+ * The struct representing a GATT_UHID-Bridge.
+ */
+struct gatt_uhid {
+ struct bt_gatt_client *client;
+
+ int fd; /* /dev/uhid file descriptor */
+ guint watch_id; /* GLib I/O watch for UHID_OUTPUT */
+
+ unsigned int *notify_ids; /* registered GATT notify IDs */
+ unsigned int notify_count;
+
+ unsigned int cccd_pending; /* CCCDs not yet confirmed */
+};
+
+
+/*
+ * A prototypical HID report descriptor.
+ *
+ * One input and one output report, each sized for the 2-byte handle
+ * prefix plus the maximum payload. The two Report Count fields
+ * (marked 0x00, 0x00) are patched at runtime.
+ */
+static const uint8_t hid_desc_template[] = {
+ 0x06, 0x00, 0xff, /* Usage Page; Vendor Defined; no hid-generic */
+ 0x09, 0x01, /* Usage; we are a vendor */
+ 0xa1, 0x01, /* Collection; <hid_descriptor> */
+
+ /* Input report */
+ 0x85, GATT_UHID_REPORT_ID,
+ 0x09, 0x01, /* Usage */
+ 0x15, 0x00, /* Logical Minimum (0) */
+ 0x26, 0xff, 0x00, /* Logical Maximum (255) */
+ 0x75, 0x08, /* Report Size (8) */
+ 0x96, 0x00, 0x00, /* Put max input size here! */
+ 0x81, 0x02, /* Input (Data, Variable, Absolute) */
+
+ /* Output report */
+ 0x85, GATT_UHID_REPORT_ID,
+ 0x09, 0x02, /* Usage */
+ 0x75, 0x08, /* Report Size (8) */
+ 0x96, 0x00, 0x00, /* Put max output size here! */
+ 0x91, 0x02, /* Output (Data, Variable, Absolute) */
+
+ 0xc0, /* End Collection; </hid_descriptor> */
+};
+
+/* Offsets used to put device max sizes into hid_desc_template */
+#define HID_DESC_MAX_INPUT_OFFSET 19
+#define HID_DESC_MAX_OUTPUT_OFFSET 30
+
+static size_t build_hid_descriptor(uint8_t *buf, uint16_t input_size,
+ uint16_t output_size)
+{
+ uint16_t in_total = GATT_UHID_HANDLE_SIZE + input_size;
+ uint16_t out_total = GATT_UHID_HANDLE_SIZE + output_size;
+
+ memcpy(buf, hid_desc_template, sizeof(hid_desc_template));
+
+ /* little endian! */
+ buf[HID_DESC_MAX_INPUT_OFFSET] = in_total & 0xff;
+ buf[HID_DESC_MAX_INPUT_OFFSET + 1] = (in_total >> 8) & 0xff;
+
+ buf[HID_DESC_MAX_OUTPUT_OFFSET] = out_total & 0xff;
+ buf[HID_DESC_MAX_OUTPUT_OFFSET + 1] = (out_total >> 8) & 0xff;
+
+ return sizeof(hid_desc_template);
+}
+
+/* Create device using params from device specific plugin */
+static int uhid_create(const struct gatt_uhid_params *params)
+{
+ struct uhid_event ev = {};
+ uint8_t hid_desc[128];
+ size_t hid_desc_len;
+ int fd;
+
+ fd = open("/dev/uhid", O_RDWR | O_CLOEXEC);
+ if (fd < 0) {
+ error("gatt-uhid: open /dev/uhid: %s", strerror(errno));
+ return -1;
+ }
+
+ hid_desc_len = build_hid_descriptor(hid_desc,
+ params->input_size,
+ params->output_size);
+
+ ev.type = UHID_CREATE2;
+ ev.u.create2.bus = BUS_BLUETOOTH;
+ ev.u.create2.vendor = params->vendor;
+ ev.u.create2.product = params->product;
+ ev.u.create2.version = params->version;
+ ev.u.create2.country = 0;
+ ev.u.create2.rd_size = hid_desc_len;
+ strncpy((char *) ev.u.create2.name, params->name, 127);
+ memcpy(ev.u.create2.rd_data, hid_desc, hid_desc_len);
+
+ if (write(fd, &ev, sizeof(ev)) < 0) {
+ error("gatt-uhid: UHID_CREATE2: %s", strerror(errno));
+ close(fd);
+ return -1;
+ }
+
+ DBG("gatt-uhid: uhid device created (%s)", params->name);
+ return fd;
+}
+
+/*
+ * From HID to BLE device
+ *
+ * UHID_OUTPUT data from the kernel HID driver:
+ * byte 0-1: GATT handle, little-endian
+ * byte 2+: payload to write
+ */
+static gboolean uhid_output_cb(GIOChannel *io, GIOCondition cond,
+ gpointer user_data)
+{
+ struct gatt_uhid *bridge = user_data;
+ struct uhid_event ev;
+ uint16_t handle;
+ ssize_t n;
+
+ if (cond & (G_IO_ERR | G_IO_HUP | G_IO_NVAL)) {
+ DBG("gatt-uhid: output_cb error/hup/nval cond=0x%x",
+ (int) cond);
+ bridge->watch_id = 0;
+ return FALSE;
+ }
+
+ n = read(bridge->fd, &ev, sizeof(ev)); /* fetch the event */
+ if (n < 0 || (size_t) n < sizeof(ev)) {
+ DBG("gatt-uhid: output_cb read returned %zd", n);
+ return TRUE;
+ }
+
+ DBG("gatt-uhid: output_cb event type=%u size=%u",
+ ev.type, ev.u.output.size);
+
+ if (ev.type != UHID_OUTPUT) {
+ DBG("gatt-uhid: output_cb ignoring event type %u", ev.type);
+ return TRUE;
+ }
+
+ if (!bridge->client) {
+ DBG("gatt-uhid: output_cb no client");
+ return TRUE;
+ }
+
+ /* Need at least the 2-byte handle prefix */
+ if (ev.u.output.size < GATT_UHID_HANDLE_SIZE) {
+ DBG("gatt-uhid: output_cb too short: %u", ev.u.output.size);
+ return TRUE;
+ }
+
+ handle = ev.u.output.data[0] | ((uint16_t) ev.u.output.data[1] << 8);
+
+ if (!handle) {
+ DBG("gatt-uhid: output_cb handle is zero");
+ return TRUE;
+ }
+
+ bt_gatt_client_write_without_response(bridge->client,
+ handle, false,
+ ev.u.output.data + GATT_UHID_HANDLE_SIZE,
+ ev.u.output.size - GATT_UHID_HANDLE_SIZE);
+ return TRUE;
+}
+
+
+/*
+ * From BLE device to HID
+ *
+ * Input report format to kernel HID driver:
+ * byte 0: report ID (0x01)
+ * byte 1-2: GATT handle, little-endian
+ * byte 3+: raw notification payload
+ */
+static void notify_cb(uint16_t value_handle, const uint8_t *value,
+ uint16_t length, void *user_data)
+{
+ struct gatt_uhid *bridge = user_data;
+ struct uhid_event ev = {};
+
+ if (length == 0 || bridge->fd < 0)
+ return;
+
+ /* Don't forward until all CCCDs are confirmed, so we don't have
+ * to deal with **some** available handles. */
+ if (bridge->cccd_pending)
+ return;
+
+ ev.type = UHID_INPUT2;
+ ev.u.input2.size = 1 + GATT_UHID_HANDLE_SIZE + length;
+ ev.u.input2.data[0] = GATT_UHID_REPORT_ID;
+ ev.u.input2.data[1] = value_handle & 0xff;
+ ev.u.input2.data[2] = (value_handle >> 8) & 0xff;
+ memcpy(&ev.u.input2.data[3], value, length);
+
+ if (write(bridge->fd, &ev, sizeof(ev)) < 0)
+ error("gatt-uhid: uhid write: %s", strerror(errno));
+}
+
+/* We can react to every single subscription on_registered */
+static void notify_registered_cb(uint16_t att_ecode, void *user_data)
+{
+ struct gatt_uhid *bridge = user_data;
+
+ if (att_ecode) {
+ error("gatt-uhid: notify registration failed: 0x%04x",
+ att_ecode);
+ }
+
+ if (bridge->cccd_pending > 0)
+ bridge->cccd_pending--;
+
+ if (bridge->cccd_pending == 0)
+ DBG("gatt-uhid: all %u CCCDs confirmed, forwarding input",
+ bridge->notify_count);
+}
+
+/*
+ * Public API
+ */
+
+struct gatt_uhid *gatt_uhid_new(struct bt_gatt_client *client,
+ const struct gatt_uhid_params *params)
+{
+ struct gatt_uhid *bridge;
+ GIOChannel *io;
+ unsigned int c;
+
+ if (!client || !params || !params->notify_count)
+ return NULL;
+
+ /* Create bridge */
+ bridge = g_new0(struct gatt_uhid, 1);
+ bridge->client = client;
+ bridge->notify_count = params->notify_count;
+ bridge->notify_ids = g_new0(unsigned int, params->notify_count);
+
+ bridge->fd = uhid_create(params); /* Create hid device */
+ if (bridge->fd < 0) {
+ g_free(bridge->notify_ids);
+ g_free(bridge);
+ return NULL;
+ }
+
+ /* Watch for UHID_OUTPUT events (commands from kernel HID driver) */
+ io = g_io_channel_unix_new(bridge->fd);
+ g_io_channel_set_encoding(io, NULL, NULL);
+ g_io_channel_set_buffered(io, FALSE);
+ bridge->watch_id = g_io_add_watch(io, G_IO_IN | G_IO_ERR | G_IO_HUP,
+ uhid_output_cb, bridge);
+ g_io_channel_unref(io);
+
+ /* Subscribe to all GATT notification handles. */
+ bridge->cccd_pending = params->notify_count;
+ for (c = 0; c < params->notify_count; c++) {
+ bridge->notify_ids[c] = bt_gatt_client_register_notify(
+ client,
+ params->notify_handles[c],
+ notify_registered_cb,
+ notify_cb,
+ bridge, NULL);
+ if (!bridge->notify_ids[c])
+ error("gatt-uhid: failed to register notify "
+ "for handle 0x%04x", params->notify_handles[c]);
+ }
+
+ return bridge;
+}
+
+void gatt_uhid_free(struct gatt_uhid *bridge)
+{
+ struct uhid_event ev = {};
+ unsigned int c;
+
+ if (!bridge)
+ return;
+
+ for (c = 0; c < bridge->notify_count; c++) {
+ if (bridge->notify_ids[c] && bridge->client)
+ bt_gatt_client_unregister_notify(bridge->client,
+ bridge->notify_ids[c]);
+ }
+
+ if (bridge->watch_id)
+ g_source_remove(bridge->watch_id);
+
+ if (bridge->fd >= 0) {
+ ev.type = UHID_DESTROY;
+ if (write(bridge->fd, &ev, sizeof(ev)) < 0)
+ error("gatt-uhid: UHID_DESTROY: %s", strerror(errno));
+ close(bridge->fd);
+ }
+
+ g_free(bridge->notify_ids);
+ g_free(bridge);
+}
diff --git a/plugins/gatt-uhid.h b/plugins/gatt-uhid.h
new file mode 100644
index 000000000..6b5e65f3b
--- /dev/null
+++ b/plugins/gatt-uhid.h
@@ -0,0 +1,56 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Generic GATT-to-UHID bridge
+ * Bridges a BLE GATT service to the kernel HID subsystem via /dev/uhid.
+ *
+ * GATT notifications are forwarded as HID input reports; ble->bluez->uhid
+ * HID output reports are forwarded back as GATT writes; hid->bluez->ble
+ *
+ * The bridge is device-agnostic — all protocol knowledge lives in the
+ * kernel HID driver that claims the uhid device by vendor/product ID.
+ *
+ * This lets the kernel driver identify which characteristic produced
+ * each input report, and target specific handles for output writes.
+ */
+
+#include <stdint.h>
+#include <stdbool.h>
+
+struct gatt_uhid;
+struct bt_gatt_client;
+
+/*
+ * Device specific plugins provide a gatt_uhid_params to
+ * configure the bridge.
+ *
+ * All GATT handles are value handles (not CCCD handles).
+ */
+struct gatt_uhid_params {
+ const char *name; /* Shown device name */
+ uint16_t vendor; /* Vendor ID like USB VID. Use this to match */
+ uint16_t product; /* Product ID like USB PID. Use this to match */
+ uint16_t version;
+
+ uint16_t *notify_handles; /* array of notification value handles */
+ unsigned int notify_count; /* number of entries in notify_handles */
+
+ /* Max payload sizes (excluding report ID and 2-byte handle prefix).*/
+ uint16_t input_size; /* size of a notification from BLE */
+ uint16_t output_size; /* size of an output from HID */
+};
+
+/*
+ * Create a GATT-UHID bridge. Opens /dev/uhid, creates the device,
+ * and subscribes to the specified GATT notification handles.
+ * Returns NULL on failure.
+ */
+struct gatt_uhid *gatt_uhid_new(struct bt_gatt_client *client,
+ const struct gatt_uhid_params *params);
+
+/*
+ * Destroy the bridge — unsubscribes GATT notifications, sends
+ * UHID_DESTROY, and frees resources.
+ */
+void gatt_uhid_free(struct gatt_uhid *bridge);
diff --git a/src/shared/gatt-client.h b/src/shared/gatt-client.h
index d9655e6f0..93fdada44 100644
--- a/src/shared/gatt-client.h
+++ b/src/shared/gatt-client.h
@@ -15,6 +15,7 @@
#define BT_GATT_UUID_SIZE 16
struct bt_gatt_client;
+struct gatt_db;
struct bt_gatt_client *bt_gatt_client_new(struct gatt_db *db,
struct bt_att *att,
--
2.47.3
next prev parent reply other threads:[~2026-04-03 8:56 UTC|newest]
Thread overview: 9+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-03 8:55 [PATCH BlueZ v3 0/6] BLE-HID/Nintendo Switch 2 support Martin BTS
2026-04-03 8:55 ` [PATCH BlueZ v3 1/6] shared/gatt: Add skip_secondary option for GATT client Martin BTS
2026-04-03 10:18 ` BLE-HID/Nintendo Switch 2 support bluez.test.bot
2026-04-03 8:55 ` [PATCH BlueZ v3 2/6] shared/gatt: Add timeout for secondary service discovery Martin BTS
2026-04-03 8:55 ` [PATCH BlueZ v3 3/6] device: Rename set_alias to btd_device_set_alias() Martin BTS
2026-04-03 8:55 ` [PATCH BlueZ v3 4/6] dbus-common: Add Gaming appearance class (0x2a) Martin BTS
2026-04-03 8:55 ` Martin BTS [this message]
2026-04-03 8:55 ` [PATCH BlueZ v3 6/6] plugins/switch2: Add Nintendo Switch 2 Controller plugin Martin BTS
-- strict thread matches above, loose matches on Subject: below --
2026-03-17 20:26 [PATCH BlueZ v3 0/6] BLE-HID/Nintendo Switch 2 support Martin BTS
2026-03-17 20:26 ` [PATCH BlueZ v3 5/6] plugins/gatt-uhid: Add generic GATT-to-UHID bridge Martin BTS
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260403085555.23871-6-martinbts@gmx.net \
--to=martinbts@gmx.net \
--cc=hadess@hadess.net \
--cc=linux-bluetooth@vger.kernel.org \
--cc=luiz.dentz@gmail.com \
--cc=vi@endrift.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox