* [PATCH] HID: logitech-headset: add a battery driver for the PRO X 2
@ 2026-09-11 11:53 Méven Car
2026-09-11 12:02 ` [PATCH v2] " Méven Car
2026-09-11 12:02 ` [PATCH] " sashiko-bot
0 siblings, 2 replies; 5+ messages in thread
From: Méven Car @ 2026-09-11 11:53 UTC (permalink / raw)
To: Jiri Kosina, Benjamin Tissoires
Cc: Filipe Laíns, linux-input, linux-kernel
The PRO X 2 LIGHTSPEED receiver does not speak HID++, so hid-logitech-hidpp
cannot read its battery. Its report descriptor has a consumer collection for
the media keys and two vendor collections, and no report 0x10 or 0x11.
The battery answers on usage page 0xffa0. A single 64 byte frame on report
0x51 asks for it, with no handshake, and the reply gives the level and
whether the headset is charging. The driver asks every 120 seconds, and it
also reads the power frames that the headset sends on its own, so a headset
that was switched off is reported before the next poll.
hid-generic sets HID_QUIRK_INPUT_PER_APP, so this receiver had one input
device per application collection, among them the Consumer Control node for
the media keys. A driver does not inherit that quirk, so probe sets it and
the input devices stay as they were.
The power supply is registered with POWER_SUPPLY_SCOPE_DEVICE, and capacity
returns -ENODATA until the first reply arrives.
The G522 LIGHTSPEED uses the same vendor protocol with a different frame
layout. That device is left out until someone can test it.
Assisted-by: Claude:claude-opus-5
---
Tested on a PRO X 2 LIGHTSPEED (046d:0af7) with 7.2.4, built as a module out
of tree. The driver binds on replug without unbinding hid-generic by hand,
and one power supply appears for the single HID interface of the receiver.
present, status and capacity read 1, Discharging and 50, against the same
frame read through hidraw: 510b000310000600040a3232.
With HID_QUIRK_INPUT_PER_APP the receiver keeps the three input devices it
had under hid-generic, among them "Logitech PRO X 2 LIGHTSPEED Consumer
Control". Without the quirk the three became one and that node was gone.
UPower exports the supply as an audio-device peripheral, which is what the
KDE battery applet lists.
Not tested: the G522 LIGHTSPEED, which is why it is absent from the id
table, and the hid_hw_raw_request fallback in logi_headset_ask, because this
receiver has an interrupt out endpoint and always takes the
hid_hw_output_report path.
The one checkpatch warning that is left is the ENOSYS one, and it matches
how hid-input.c and hidraw.c test the return of hid_hw_output_report before
they fall back to hid_hw_raw_request.
Per Documentation/process/generated-content.rst: this patch was written with
Claude Code, model claude-opus-5. The protocol was read from HeadsetControl
and confirmed with a python script that sent the frame over hidraw on the
real device. The model wrote the driver, the Kconfig and Makefile entries
and this changelog from that, and the result was built and tested on the
hardware described above.
Based on v7.2.3, so it may need a rebase onto hid.git.
drivers/hid/Kconfig | 11 ++
drivers/hid/Makefile | 1 +
drivers/hid/hid-ids.h | 1 +
drivers/hid/hid-logitech-headset.c | 292 +++++++++++++++++++++++++++++
4 files changed, 305 insertions(+)
create mode 100644 drivers/hid/hid-logitech-headset.c
diff --git a/drivers/hid/Kconfig b/drivers/hid/Kconfig
index 48934c4f3..5c845b1b8 100644
--- a/drivers/hid/Kconfig
+++ b/drivers/hid/Kconfig
@@ -670,6 +670,17 @@ config HID_LOGITECH_DJ
generic USB_HID driver and all incoming events will be multiplexed
into a single mouse and a single keyboard device.
+config HID_LOGITECH_HEADSET
+ tristate "Logitech wireless headset battery"
+ depends on USB_HID
+ select POWER_SUPPLY
+ help
+ Support for the battery of Logitech wireless gaming headsets that
+ report it over a vendor collection instead of HID++.
+
+ Supported devices:
+ - PRO X 2 LIGHTSPEED
+
config HID_LOGITECH_HIDPP
tristate "Logitech HID++ devices support"
depends on HID_LOGITECH
diff --git a/drivers/hid/Makefile b/drivers/hid/Makefile
index 23e6e3dd0..b8576a5ac 100644
--- a/drivers/hid/Makefile
+++ b/drivers/hid/Makefile
@@ -82,6 +82,7 @@ obj-$(CONFIG_HID_LETSKETCH) += hid-letsketch.o
obj-$(CONFIG_HID_LOGITECH) += hid-logitech.o
obj-$(CONFIG_HID_LOGITECH) += hid-lg-g15.o
obj-$(CONFIG_HID_LOGITECH_DJ) += hid-logitech-dj.o
+obj-$(CONFIG_HID_LOGITECH_HEADSET) += hid-logitech-headset.o
obj-$(CONFIG_HID_LOGITECH_HIDPP) += hid-logitech-hidpp.o
obj-$(CONFIG_HID_MACALLY) += hid-macally.o
obj-$(CONFIG_HID_MAGICMOUSE) += hid-magicmouse.o
diff --git a/drivers/hid/hid-ids.h b/drivers/hid/hid-ids.h
index 1059922ba..0efe30b2f 100644
--- a/drivers/hid/hid-ids.h
+++ b/drivers/hid/hid-ids.h
@@ -910,6 +910,7 @@
#define USB_VENDOR_ID_LOGITECH 0x046d
#define USB_DEVICE_ID_LOGITECH_Z_10_SPK 0x0a07
#define USB_DEVICE_ID_LOGITECH_AUDIOHUB 0x0a0e
+#define USB_DEVICE_ID_LOGITECH_PRO_X_2_LIGHTSPEED 0x0af7
#define USB_DEVICE_ID_LOGITECH_T651 0xb00c
#define USB_DEVICE_ID_LOGITECH_DINOVO_EDGE_KBD 0xb309
#define USB_DEVICE_ID_LOGITECH_CASA_TOUCHPAD 0xbb00
diff --git a/drivers/hid/hid-logitech-headset.c b/drivers/hid/hid-logitech-headset.c
new file mode 100644
index 000000000..9081ddf5f
--- /dev/null
+++ b/drivers/hid/hid-logitech-headset.c
@@ -0,0 +1,292 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * HID driver for the battery of Logitech wireless gaming headsets that report
+ * it over a vendor collection instead of HID++.
+ *
+ * Copyright (c) 2026 Méven Car <meven@kde.org>
+ */
+
+#include <linux/hid.h>
+#include <linux/module.h>
+#include <linux/power_supply.h>
+#include <linux/slab.h>
+#include <linux/spinlock.h>
+#include <linux/workqueue.h>
+
+#include "hid-ids.h"
+
+/*
+ * The receiver carries a consumer collection for the media keys and two vendor
+ * collections. The battery answers on usage page 0xffa0, which exchanges fixed
+ * size frames on report 0x51. One request is enough, there is no handshake.
+ */
+#define LOGI_HEADSET_REPORT_ID 0x51
+#define LOGI_HEADSET_FRAME_SIZE 64
+
+/* Every frame names its kind in the second byte. */
+#define LOGI_HEADSET_KIND_ACK 0x03
+#define LOGI_HEADSET_KIND_POWER 0x05
+#define LOGI_HEADSET_KIND_BATTERY 0x0b
+
+/* Offsets into a battery frame, which carries a tag of its own. */
+#define LOGI_HEADSET_BATTERY_TAG 8
+#define LOGI_HEADSET_BATTERY_TAG_VALUE 0x04
+#define LOGI_HEADSET_BATTERY_LEVEL 10
+#define LOGI_HEADSET_BATTERY_STATE 12
+#define LOGI_HEADSET_STATE_CHARGING 0x02
+
+/* A power frame carries zero here once the headset has been switched off. */
+#define LOGI_HEADSET_POWER_STATE 6
+
+#define LOGI_HEADSET_POLL_INTERVAL (120 * HZ)
+
+static const u8 logi_headset_battery_request[] = {
+ LOGI_HEADSET_REPORT_ID, 0x08, 0x00, 0x03, 0x1a, 0x00, 0x03, 0x00, 0x04, 0x0a
+};
+
+struct logi_headset {
+ struct hid_device *hdev;
+ struct power_supply *battery;
+ struct power_supply_desc desc;
+ struct delayed_work poll;
+ spinlock_t lock; /* guards the values below */
+ int capacity;
+ bool charging;
+ bool present;
+ bool seen; /* an answer has arrived at least once */
+};
+
+static enum power_supply_property logi_headset_properties[] = {
+ POWER_SUPPLY_PROP_PRESENT,
+ POWER_SUPPLY_PROP_STATUS,
+ POWER_SUPPLY_PROP_CAPACITY,
+ POWER_SUPPLY_PROP_CAPACITY_LEVEL,
+ POWER_SUPPLY_PROP_SCOPE,
+ POWER_SUPPLY_PROP_MODEL_NAME,
+ POWER_SUPPLY_PROP_MANUFACTURER,
+};
+
+static int logi_headset_get_property(struct power_supply *psy,
+ enum power_supply_property prop,
+ union power_supply_propval *val)
+{
+ struct logi_headset *headset = power_supply_get_drvdata(psy);
+ unsigned long flags;
+ int ret = 0;
+
+ spin_lock_irqsave(&headset->lock, flags);
+ switch (prop) {
+ case POWER_SUPPLY_PROP_PRESENT:
+ val->intval = headset->present;
+ break;
+ case POWER_SUPPLY_PROP_STATUS:
+ if (!headset->present)
+ val->intval = POWER_SUPPLY_STATUS_UNKNOWN;
+ else if (headset->charging)
+ val->intval = POWER_SUPPLY_STATUS_CHARGING;
+ else
+ val->intval = POWER_SUPPLY_STATUS_DISCHARGING;
+ break;
+ case POWER_SUPPLY_PROP_CAPACITY:
+ if (headset->seen)
+ val->intval = headset->capacity;
+ else
+ ret = -ENODATA;
+ break;
+ case POWER_SUPPLY_PROP_CAPACITY_LEVEL:
+ if (!headset->seen)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_UNKNOWN;
+ else if (headset->capacity <= 10)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_CRITICAL;
+ else if (headset->capacity <= 25)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_LOW;
+ else if (headset->capacity >= 95)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_FULL;
+ else
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_NORMAL;
+ break;
+ case POWER_SUPPLY_PROP_SCOPE:
+ /* A peripheral, not the battery of the machine itself. */
+ val->intval = POWER_SUPPLY_SCOPE_DEVICE;
+ break;
+ case POWER_SUPPLY_PROP_MODEL_NAME:
+ val->strval = headset->hdev->name;
+ break;
+ case POWER_SUPPLY_PROP_MANUFACTURER:
+ val->strval = "Logitech";
+ break;
+ default:
+ ret = -EINVAL;
+ break;
+ }
+ spin_unlock_irqrestore(&headset->lock, flags);
+
+ return ret;
+}
+
+static int logi_headset_ask(struct logi_headset *headset)
+{
+ u8 *frame;
+ int ret;
+
+ frame = kzalloc(LOGI_HEADSET_FRAME_SIZE, GFP_KERNEL);
+ if (!frame)
+ return -ENOMEM;
+
+ memcpy(frame, logi_headset_battery_request,
+ sizeof(logi_headset_battery_request));
+
+ /* Same fallback as hidraw, for a device without an interrupt out endpoint. */
+ ret = hid_hw_output_report(headset->hdev, frame, LOGI_HEADSET_FRAME_SIZE);
+ if (ret == -ENOSYS)
+ ret = hid_hw_raw_request(headset->hdev, frame[0], frame,
+ LOGI_HEADSET_FRAME_SIZE,
+ HID_OUTPUT_REPORT, HID_REQ_SET_REPORT);
+ kfree(frame);
+
+ return ret;
+}
+
+static void logi_headset_poll(struct work_struct *work)
+{
+ struct logi_headset *headset = container_of(to_delayed_work(work),
+ struct logi_headset, poll);
+ int ret;
+
+ ret = logi_headset_ask(headset);
+ if (ret < 0)
+ hid_dbg(headset->hdev, "battery request failed: %d\n", ret);
+
+ schedule_delayed_work(&headset->poll, LOGI_HEADSET_POLL_INTERVAL);
+}
+
+static int logi_headset_raw_event(struct hid_device *hdev,
+ struct hid_report *report, u8 *data, int size)
+{
+ struct logi_headset *headset = hid_get_drvdata(hdev);
+ unsigned long flags;
+ bool changed = false;
+
+ if (size <= LOGI_HEADSET_BATTERY_STATE ||
+ data[0] != LOGI_HEADSET_REPORT_ID)
+ return 0;
+
+ spin_lock_irqsave(&headset->lock, flags);
+ switch (data[1]) {
+ case LOGI_HEADSET_KIND_BATTERY:
+ if (data[LOGI_HEADSET_BATTERY_TAG] == LOGI_HEADSET_BATTERY_TAG_VALUE &&
+ data[LOGI_HEADSET_BATTERY_LEVEL] <= 100) {
+ headset->capacity = data[LOGI_HEADSET_BATTERY_LEVEL];
+ headset->charging = data[LOGI_HEADSET_BATTERY_STATE] ==
+ LOGI_HEADSET_STATE_CHARGING;
+ headset->present = true;
+ headset->seen = true;
+ changed = true;
+ }
+ break;
+ case LOGI_HEADSET_KIND_POWER:
+ if (data[LOGI_HEADSET_POWER_STATE] == 0x00 && headset->present) {
+ headset->present = false;
+ changed = true;
+ }
+ break;
+ case LOGI_HEADSET_KIND_ACK:
+ default:
+ break;
+ }
+ spin_unlock_irqrestore(&headset->lock, flags);
+
+ if (changed)
+ power_supply_changed(headset->battery);
+
+ /* Let the frame reach hidraw as well, userspace tools read it too. */
+ return 0;
+}
+
+static int logi_headset_probe(struct hid_device *hdev,
+ const struct hid_device_id *id)
+{
+ struct power_supply_config cfg = {};
+ struct logi_headset *headset;
+ const char *name;
+ int ret;
+
+ headset = devm_kzalloc(&hdev->dev, sizeof(*headset), GFP_KERNEL);
+ if (!headset)
+ return -ENOMEM;
+
+ headset->hdev = hdev;
+ headset->present = true;
+ spin_lock_init(&headset->lock);
+ INIT_DELAYED_WORK(&headset->poll, logi_headset_poll);
+ hid_set_drvdata(hdev, headset);
+
+ /* As hid-generic did, so that the media keys keep their own input device. */
+ hdev->quirks |= HID_QUIRK_INPUT_PER_APP;
+
+ ret = hid_parse(hdev);
+ if (ret)
+ return ret;
+
+ name = devm_kasprintf(&hdev->dev, GFP_KERNEL,
+ "logitech-headset-%d-battery", hdev->id);
+ if (!name)
+ return -ENOMEM;
+
+ headset->desc.name = name;
+ headset->desc.type = POWER_SUPPLY_TYPE_BATTERY;
+ headset->desc.properties = logi_headset_properties;
+ headset->desc.num_properties = ARRAY_SIZE(logi_headset_properties);
+ headset->desc.get_property = logi_headset_get_property;
+ cfg.drv_data = headset;
+
+ headset->battery = devm_power_supply_register(&hdev->dev, &headset->desc,
+ &cfg);
+ if (IS_ERR(headset->battery))
+ return dev_err_probe(&hdev->dev, PTR_ERR(headset->battery),
+ "cannot register the battery\n");
+
+ ret = hid_hw_start(hdev, HID_CONNECT_DEFAULT);
+ if (ret)
+ return ret;
+
+ /* Input reports only arrive while the transport is open. */
+ ret = hid_hw_open(hdev);
+ if (ret) {
+ hid_hw_stop(hdev);
+ return ret;
+ }
+
+ schedule_delayed_work(&headset->poll, HZ);
+
+ return 0;
+}
+
+static void logi_headset_remove(struct hid_device *hdev)
+{
+ struct logi_headset *headset = hid_get_drvdata(hdev);
+
+ cancel_delayed_work_sync(&headset->poll);
+ hid_hw_close(hdev);
+ hid_hw_stop(hdev);
+}
+
+static const struct hid_device_id logi_headset_devices[] = {
+ { HID_USB_DEVICE(USB_VENDOR_ID_LOGITECH,
+ USB_DEVICE_ID_LOGITECH_PRO_X_2_LIGHTSPEED) },
+ { }
+};
+MODULE_DEVICE_TABLE(hid, logi_headset_devices);
+
+static struct hid_driver logi_headset_driver = {
+ .name = "logitech-headset",
+ .id_table = logi_headset_devices,
+ .probe = logi_headset_probe,
+ .remove = logi_headset_remove,
+ .raw_event = logi_headset_raw_event,
+};
+module_hid_driver(logi_headset_driver);
+
+MODULE_AUTHOR("Méven Car <meven@kde.org>");
+MODULE_DESCRIPTION("Battery for Logitech wireless headsets without HID++");
+MODULE_LICENSE("GPL");
--
2.55.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
* [PATCH v2] HID: logitech-headset: add a battery driver for the PRO X 2
2026-09-11 11:53 [PATCH] HID: logitech-headset: add a battery driver for the PRO X 2 Méven Car
@ 2026-09-11 12:02 ` Méven Car
2026-09-11 12:10 ` sashiko-bot
2026-09-11 12:02 ` [PATCH] " sashiko-bot
1 sibling, 1 reply; 5+ messages in thread
From: Méven Car @ 2026-09-11 12:02 UTC (permalink / raw)
To: Jiri Kosina, Benjamin Tissoires
Cc: Filipe Laíns, linux-input, linux-kernel
The PRO X 2 LIGHTSPEED receiver does not speak HID++, so hid-logitech-hidpp
cannot read its battery. Its report descriptor has a consumer collection for
the media keys and two vendor collections, and no report 0x10 or 0x11.
The battery answers on usage page 0xffa0. A single 64 byte frame on report
0x51 asks for it, with no handshake, and the reply gives the level and
whether the headset is charging. The driver asks every 120 seconds, and it
also reads the power frames that the headset sends on its own, so a headset
that was switched off is reported before the next poll.
hid-generic sets HID_QUIRK_INPUT_PER_APP, so this receiver had one input
device per application collection, among them the Consumer Control node for
the media keys. A driver does not inherit that quirk, so probe sets it and
the input devices stay as they were.
The power supply is registered with POWER_SUPPLY_SCOPE_DEVICE, and capacity
returns -ENODATA until the first reply arrives.
The G522 LIGHTSPEED uses the same vendor protocol with a different frame
layout. That device is left out until someone can test it.
Assisted-by: Claude:claude-opus-5
Signed-off-by: Méven Car <meven@kde.org>
---
Changes since v1: the Signed-off-by line, which v1 was sent without by
mistake. The patch itself is unchanged.
Tested on a PRO X 2 LIGHTSPEED (046d:0af7) with 7.2.4, built as a module out
of tree. The driver binds on replug without unbinding hid-generic by hand,
and one power supply appears for the single HID interface of the receiver.
present, status and capacity read 1, Discharging and 50, against the same
frame read through hidraw: 510b000310000600040a3232.
With HID_QUIRK_INPUT_PER_APP the receiver keeps the three input devices it
had under hid-generic, among them "Logitech PRO X 2 LIGHTSPEED Consumer
Control". Without the quirk the three became one and that node was gone.
UPower exports the supply as an audio-device peripheral, which is what the
KDE battery applet lists.
Not tested: the G522 LIGHTSPEED, which is why it is absent from the id
table, and the hid_hw_raw_request fallback in logi_headset_ask, because this
receiver has an interrupt out endpoint and always takes the
hid_hw_output_report path.
The one checkpatch warning that is left is the ENOSYS one, and it matches
how hid-input.c and hidraw.c test the return of hid_hw_output_report before
they fall back to hid_hw_raw_request.
Per Documentation/process/generated-content.rst: this patch was written with
Claude Code, model claude-opus-5. The protocol was read from HeadsetControl
and confirmed with a python script that sent the frame over hidraw on the
real device. The model wrote the driver, the Kconfig and Makefile entries
and this changelog from that, and the result was built and tested on the
hardware described above.
Based on v7.2.3, so it may need a rebase onto hid.git.
drivers/hid/Kconfig | 11 ++
drivers/hid/Makefile | 1 +
drivers/hid/hid-ids.h | 1 +
drivers/hid/hid-logitech-headset.c | 292 +++++++++++++++++++++++++++++
4 files changed, 305 insertions(+)
create mode 100644 drivers/hid/hid-logitech-headset.c
diff --git a/drivers/hid/Kconfig b/drivers/hid/Kconfig
index 48934c4f3..5c845b1b8 100644
--- a/drivers/hid/Kconfig
+++ b/drivers/hid/Kconfig
@@ -670,6 +670,17 @@ config HID_LOGITECH_DJ
generic USB_HID driver and all incoming events will be multiplexed
into a single mouse and a single keyboard device.
+config HID_LOGITECH_HEADSET
+ tristate "Logitech wireless headset battery"
+ depends on USB_HID
+ select POWER_SUPPLY
+ help
+ Support for the battery of Logitech wireless gaming headsets that
+ report it over a vendor collection instead of HID++.
+
+ Supported devices:
+ - PRO X 2 LIGHTSPEED
+
config HID_LOGITECH_HIDPP
tristate "Logitech HID++ devices support"
depends on HID_LOGITECH
diff --git a/drivers/hid/Makefile b/drivers/hid/Makefile
index 23e6e3dd0..b8576a5ac 100644
--- a/drivers/hid/Makefile
+++ b/drivers/hid/Makefile
@@ -82,6 +82,7 @@ obj-$(CONFIG_HID_LETSKETCH) += hid-letsketch.o
obj-$(CONFIG_HID_LOGITECH) += hid-logitech.o
obj-$(CONFIG_HID_LOGITECH) += hid-lg-g15.o
obj-$(CONFIG_HID_LOGITECH_DJ) += hid-logitech-dj.o
+obj-$(CONFIG_HID_LOGITECH_HEADSET) += hid-logitech-headset.o
obj-$(CONFIG_HID_LOGITECH_HIDPP) += hid-logitech-hidpp.o
obj-$(CONFIG_HID_MACALLY) += hid-macally.o
obj-$(CONFIG_HID_MAGICMOUSE) += hid-magicmouse.o
diff --git a/drivers/hid/hid-ids.h b/drivers/hid/hid-ids.h
index 1059922ba..0efe30b2f 100644
--- a/drivers/hid/hid-ids.h
+++ b/drivers/hid/hid-ids.h
@@ -910,6 +910,7 @@
#define USB_VENDOR_ID_LOGITECH 0x046d
#define USB_DEVICE_ID_LOGITECH_Z_10_SPK 0x0a07
#define USB_DEVICE_ID_LOGITECH_AUDIOHUB 0x0a0e
+#define USB_DEVICE_ID_LOGITECH_PRO_X_2_LIGHTSPEED 0x0af7
#define USB_DEVICE_ID_LOGITECH_T651 0xb00c
#define USB_DEVICE_ID_LOGITECH_DINOVO_EDGE_KBD 0xb309
#define USB_DEVICE_ID_LOGITECH_CASA_TOUCHPAD 0xbb00
diff --git a/drivers/hid/hid-logitech-headset.c b/drivers/hid/hid-logitech-headset.c
new file mode 100644
index 000000000..9081ddf5f
--- /dev/null
+++ b/drivers/hid/hid-logitech-headset.c
@@ -0,0 +1,292 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * HID driver for the battery of Logitech wireless gaming headsets that report
+ * it over a vendor collection instead of HID++.
+ *
+ * Copyright (c) 2026 Méven Car <meven@kde.org>
+ */
+
+#include <linux/hid.h>
+#include <linux/module.h>
+#include <linux/power_supply.h>
+#include <linux/slab.h>
+#include <linux/spinlock.h>
+#include <linux/workqueue.h>
+
+#include "hid-ids.h"
+
+/*
+ * The receiver carries a consumer collection for the media keys and two vendor
+ * collections. The battery answers on usage page 0xffa0, which exchanges fixed
+ * size frames on report 0x51. One request is enough, there is no handshake.
+ */
+#define LOGI_HEADSET_REPORT_ID 0x51
+#define LOGI_HEADSET_FRAME_SIZE 64
+
+/* Every frame names its kind in the second byte. */
+#define LOGI_HEADSET_KIND_ACK 0x03
+#define LOGI_HEADSET_KIND_POWER 0x05
+#define LOGI_HEADSET_KIND_BATTERY 0x0b
+
+/* Offsets into a battery frame, which carries a tag of its own. */
+#define LOGI_HEADSET_BATTERY_TAG 8
+#define LOGI_HEADSET_BATTERY_TAG_VALUE 0x04
+#define LOGI_HEADSET_BATTERY_LEVEL 10
+#define LOGI_HEADSET_BATTERY_STATE 12
+#define LOGI_HEADSET_STATE_CHARGING 0x02
+
+/* A power frame carries zero here once the headset has been switched off. */
+#define LOGI_HEADSET_POWER_STATE 6
+
+#define LOGI_HEADSET_POLL_INTERVAL (120 * HZ)
+
+static const u8 logi_headset_battery_request[] = {
+ LOGI_HEADSET_REPORT_ID, 0x08, 0x00, 0x03, 0x1a, 0x00, 0x03, 0x00, 0x04, 0x0a
+};
+
+struct logi_headset {
+ struct hid_device *hdev;
+ struct power_supply *battery;
+ struct power_supply_desc desc;
+ struct delayed_work poll;
+ spinlock_t lock; /* guards the values below */
+ int capacity;
+ bool charging;
+ bool present;
+ bool seen; /* an answer has arrived at least once */
+};
+
+static enum power_supply_property logi_headset_properties[] = {
+ POWER_SUPPLY_PROP_PRESENT,
+ POWER_SUPPLY_PROP_STATUS,
+ POWER_SUPPLY_PROP_CAPACITY,
+ POWER_SUPPLY_PROP_CAPACITY_LEVEL,
+ POWER_SUPPLY_PROP_SCOPE,
+ POWER_SUPPLY_PROP_MODEL_NAME,
+ POWER_SUPPLY_PROP_MANUFACTURER,
+};
+
+static int logi_headset_get_property(struct power_supply *psy,
+ enum power_supply_property prop,
+ union power_supply_propval *val)
+{
+ struct logi_headset *headset = power_supply_get_drvdata(psy);
+ unsigned long flags;
+ int ret = 0;
+
+ spin_lock_irqsave(&headset->lock, flags);
+ switch (prop) {
+ case POWER_SUPPLY_PROP_PRESENT:
+ val->intval = headset->present;
+ break;
+ case POWER_SUPPLY_PROP_STATUS:
+ if (!headset->present)
+ val->intval = POWER_SUPPLY_STATUS_UNKNOWN;
+ else if (headset->charging)
+ val->intval = POWER_SUPPLY_STATUS_CHARGING;
+ else
+ val->intval = POWER_SUPPLY_STATUS_DISCHARGING;
+ break;
+ case POWER_SUPPLY_PROP_CAPACITY:
+ if (headset->seen)
+ val->intval = headset->capacity;
+ else
+ ret = -ENODATA;
+ break;
+ case POWER_SUPPLY_PROP_CAPACITY_LEVEL:
+ if (!headset->seen)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_UNKNOWN;
+ else if (headset->capacity <= 10)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_CRITICAL;
+ else if (headset->capacity <= 25)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_LOW;
+ else if (headset->capacity >= 95)
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_FULL;
+ else
+ val->intval = POWER_SUPPLY_CAPACITY_LEVEL_NORMAL;
+ break;
+ case POWER_SUPPLY_PROP_SCOPE:
+ /* A peripheral, not the battery of the machine itself. */
+ val->intval = POWER_SUPPLY_SCOPE_DEVICE;
+ break;
+ case POWER_SUPPLY_PROP_MODEL_NAME:
+ val->strval = headset->hdev->name;
+ break;
+ case POWER_SUPPLY_PROP_MANUFACTURER:
+ val->strval = "Logitech";
+ break;
+ default:
+ ret = -EINVAL;
+ break;
+ }
+ spin_unlock_irqrestore(&headset->lock, flags);
+
+ return ret;
+}
+
+static int logi_headset_ask(struct logi_headset *headset)
+{
+ u8 *frame;
+ int ret;
+
+ frame = kzalloc(LOGI_HEADSET_FRAME_SIZE, GFP_KERNEL);
+ if (!frame)
+ return -ENOMEM;
+
+ memcpy(frame, logi_headset_battery_request,
+ sizeof(logi_headset_battery_request));
+
+ /* Same fallback as hidraw, for a device without an interrupt out endpoint. */
+ ret = hid_hw_output_report(headset->hdev, frame, LOGI_HEADSET_FRAME_SIZE);
+ if (ret == -ENOSYS)
+ ret = hid_hw_raw_request(headset->hdev, frame[0], frame,
+ LOGI_HEADSET_FRAME_SIZE,
+ HID_OUTPUT_REPORT, HID_REQ_SET_REPORT);
+ kfree(frame);
+
+ return ret;
+}
+
+static void logi_headset_poll(struct work_struct *work)
+{
+ struct logi_headset *headset = container_of(to_delayed_work(work),
+ struct logi_headset, poll);
+ int ret;
+
+ ret = logi_headset_ask(headset);
+ if (ret < 0)
+ hid_dbg(headset->hdev, "battery request failed: %d\n", ret);
+
+ schedule_delayed_work(&headset->poll, LOGI_HEADSET_POLL_INTERVAL);
+}
+
+static int logi_headset_raw_event(struct hid_device *hdev,
+ struct hid_report *report, u8 *data, int size)
+{
+ struct logi_headset *headset = hid_get_drvdata(hdev);
+ unsigned long flags;
+ bool changed = false;
+
+ if (size <= LOGI_HEADSET_BATTERY_STATE ||
+ data[0] != LOGI_HEADSET_REPORT_ID)
+ return 0;
+
+ spin_lock_irqsave(&headset->lock, flags);
+ switch (data[1]) {
+ case LOGI_HEADSET_KIND_BATTERY:
+ if (data[LOGI_HEADSET_BATTERY_TAG] == LOGI_HEADSET_BATTERY_TAG_VALUE &&
+ data[LOGI_HEADSET_BATTERY_LEVEL] <= 100) {
+ headset->capacity = data[LOGI_HEADSET_BATTERY_LEVEL];
+ headset->charging = data[LOGI_HEADSET_BATTERY_STATE] ==
+ LOGI_HEADSET_STATE_CHARGING;
+ headset->present = true;
+ headset->seen = true;
+ changed = true;
+ }
+ break;
+ case LOGI_HEADSET_KIND_POWER:
+ if (data[LOGI_HEADSET_POWER_STATE] == 0x00 && headset->present) {
+ headset->present = false;
+ changed = true;
+ }
+ break;
+ case LOGI_HEADSET_KIND_ACK:
+ default:
+ break;
+ }
+ spin_unlock_irqrestore(&headset->lock, flags);
+
+ if (changed)
+ power_supply_changed(headset->battery);
+
+ /* Let the frame reach hidraw as well, userspace tools read it too. */
+ return 0;
+}
+
+static int logi_headset_probe(struct hid_device *hdev,
+ const struct hid_device_id *id)
+{
+ struct power_supply_config cfg = {};
+ struct logi_headset *headset;
+ const char *name;
+ int ret;
+
+ headset = devm_kzalloc(&hdev->dev, sizeof(*headset), GFP_KERNEL);
+ if (!headset)
+ return -ENOMEM;
+
+ headset->hdev = hdev;
+ headset->present = true;
+ spin_lock_init(&headset->lock);
+ INIT_DELAYED_WORK(&headset->poll, logi_headset_poll);
+ hid_set_drvdata(hdev, headset);
+
+ /* As hid-generic did, so that the media keys keep their own input device. */
+ hdev->quirks |= HID_QUIRK_INPUT_PER_APP;
+
+ ret = hid_parse(hdev);
+ if (ret)
+ return ret;
+
+ name = devm_kasprintf(&hdev->dev, GFP_KERNEL,
+ "logitech-headset-%d-battery", hdev->id);
+ if (!name)
+ return -ENOMEM;
+
+ headset->desc.name = name;
+ headset->desc.type = POWER_SUPPLY_TYPE_BATTERY;
+ headset->desc.properties = logi_headset_properties;
+ headset->desc.num_properties = ARRAY_SIZE(logi_headset_properties);
+ headset->desc.get_property = logi_headset_get_property;
+ cfg.drv_data = headset;
+
+ headset->battery = devm_power_supply_register(&hdev->dev, &headset->desc,
+ &cfg);
+ if (IS_ERR(headset->battery))
+ return dev_err_probe(&hdev->dev, PTR_ERR(headset->battery),
+ "cannot register the battery\n");
+
+ ret = hid_hw_start(hdev, HID_CONNECT_DEFAULT);
+ if (ret)
+ return ret;
+
+ /* Input reports only arrive while the transport is open. */
+ ret = hid_hw_open(hdev);
+ if (ret) {
+ hid_hw_stop(hdev);
+ return ret;
+ }
+
+ schedule_delayed_work(&headset->poll, HZ);
+
+ return 0;
+}
+
+static void logi_headset_remove(struct hid_device *hdev)
+{
+ struct logi_headset *headset = hid_get_drvdata(hdev);
+
+ cancel_delayed_work_sync(&headset->poll);
+ hid_hw_close(hdev);
+ hid_hw_stop(hdev);
+}
+
+static const struct hid_device_id logi_headset_devices[] = {
+ { HID_USB_DEVICE(USB_VENDOR_ID_LOGITECH,
+ USB_DEVICE_ID_LOGITECH_PRO_X_2_LIGHTSPEED) },
+ { }
+};
+MODULE_DEVICE_TABLE(hid, logi_headset_devices);
+
+static struct hid_driver logi_headset_driver = {
+ .name = "logitech-headset",
+ .id_table = logi_headset_devices,
+ .probe = logi_headset_probe,
+ .remove = logi_headset_remove,
+ .raw_event = logi_headset_raw_event,
+};
+module_hid_driver(logi_headset_driver);
+
+MODULE_AUTHOR("Méven Car <meven@kde.org>");
+MODULE_DESCRIPTION("Battery for Logitech wireless headsets without HID++");
+MODULE_LICENSE("GPL");
--
2.55.0
^ permalink raw reply related [flat|nested] 5+ messages in thread
* Re: [PATCH] HID: logitech-headset: add a battery driver for the PRO X 2
2026-09-11 11:53 [PATCH] HID: logitech-headset: add a battery driver for the PRO X 2 Méven Car
2026-09-11 12:02 ` [PATCH v2] " Méven Car
@ 2026-09-11 12:02 ` sashiko-bot
1 sibling, 0 replies; 5+ messages in thread
From: sashiko-bot @ 2026-09-11 12:02 UTC (permalink / raw)
To: Méven Car; +Cc: dmitry.torokhov, linux-input
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [High] Incorrect teardown sequence in remove callback violates HID subsystem guidelines.
--
commit adb0241c4db099ec7ce91b1ebd7f292627e20d96
Author: Méven Car <meven29@gmail.com>
HID: logitech-headset: add a battery driver for the PRO X 2
This commit introduces a battery driver for the Logitech PRO X 2 LIGHTSPEED
headset. It communicates with the hardware over a vendor collection using a
polling mechanism scheduled via a delayed work queue to report battery status.
[ ... ]
> diff --git a/drivers/hid/hid-logitech-headset.c b/drivers/hid/hid-logitech-headset.c
> new file mode 100644
> index 0000000000000..9081ddf5f42f2
> --- /dev/null
> +++ b/drivers/hid/hid-logitech-headset.c
[ ... ]
> +static void logi_headset_remove(struct hid_device *hdev)
> +{
> + struct logi_headset *headset = hid_get_drvdata(hdev);
> +
> + cancel_delayed_work_sync(&headset->poll);
[Severity: High]
Does this teardown sequence in logi_headset_remove risk a use-after-free?
The HID subsystem guidelines specify that hid_hw_stop must be called before
cancelling asynchronous workers. By executing cancel_delayed_work_sync
before hid_hw_stop, it is theoretically possible for new work to be queued
while the driver is in the process of unbinding.
Calling hid_hw_stop first ensures that no new events or userspace requests
can trigger work to be scheduled during resource reclamation.
> + hid_hw_close(hdev);
> + hid_hw_stop(hdev);
> +}
[ ... ]
--
Sashiko AI review · https://sashiko.dev/#/patchset/20260911115346.17133-1-meven@kde.org?part=1
^ permalink raw reply [flat|nested] 5+ messages in thread
* Re: [PATCH v2] HID: logitech-headset: add a battery driver for the PRO X 2
2026-09-11 12:02 ` [PATCH v2] " Méven Car
@ 2026-09-11 12:10 ` sashiko-bot
2026-09-11 13:53 ` Méven
0 siblings, 1 reply; 5+ messages in thread
From: sashiko-bot @ 2026-09-11 12:10 UTC (permalink / raw)
To: Méven Car; +Cc: dmitry.torokhov, linux-input
Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [High] The driver cancels its delayed workqueue before calling hid_hw_stop(), which violates the mandatory HID subsystem cleanup order.
--
commit bd334ae3e07d607cf268d197e4923d3fe7f27cad
Author: Méven Car <meven29@gmail.com>
HID: logitech-headset: add a battery driver for the PRO X 2
This commit introduces a battery driver for the Logitech PRO X 2 LIGHTSPEED
headset. The device reports its battery status over a vendor collection
rather than HID++, so a custom driver is implemented to poll and parse these
reports.
> diff --git a/drivers/hid/hid-logitech-headset.c b/drivers/hid/hid-logitech-headset.c
> new file mode 100644
> index 0000000000000..9081ddf5f42f2
> --- /dev/null
> +++ b/drivers/hid/hid-logitech-headset.c
[ ... ]
> +static void logi_headset_remove(struct hid_device *hdev)
> +{
> + struct logi_headset *headset = hid_get_drvdata(hdev);
> +
> + cancel_delayed_work_sync(&headset->poll);
> + hid_hw_close(hdev);
> + hid_hw_stop(hdev);
[Severity: High]
Could this teardown sequence in logi_headset_remove() lead to a
use-after-free?
In drivers/hid/hid-logitech-headset.c:logi_headset_remove(), the delayed work
is cancelled before hid_hw_stop() is called. When a device is unplugged or
unbound, cancelling workers before stopping the hardware (which unregisters
the device interfaces) means that hardware events or userspace interactions
could theoretically queue new work after the cancellation.
Since devres automatically frees the driver's private data after remove()
returns, any lingering work executing subsequently would access freed memory.
Calling hid_hw_stop() before cancelling the work would ensure no new work can
be queued.
> +}
--
Sashiko AI review · https://sashiko.dev/#/patchset/20260911120208.21698-1-meven@kde.org?part=1
^ permalink raw reply [flat|nested] 5+ messages in thread
* Re: [PATCH v2] HID: logitech-headset: add a battery driver for the PRO X 2
2026-09-11 12:10 ` sashiko-bot
@ 2026-09-11 13:53 ` Méven
0 siblings, 0 replies; 5+ messages in thread
From: Méven @ 2026-09-11 13:53 UTC (permalink / raw)
To: sashiko-reviews; +Cc: dmitry.torokhov, linux-input
> The HID subsystem guidelines specify that hid_hw_stop must be called before
> cancelling asynchronous workers.
Stopping first is the unsafe order here. logi_headset_poll() ends in
hid_hw_output_report(), so a work item still running would send on a
transport hid_hw_stop() has already torn down.
drivers/hid mostly cancels first: steelseries, steam, kysona, asus,
nvidia-shield. corsair-void stops first, but its battery_work only calls
power_supply_unregister() and power_supply_changed() and never touches the
device.
cancel_work_sync() handles a work that re-arms itself. The only
schedule_delayed_work() calls here are in probe() and at the end of the work,
raw_event() never schedules.
Which guideline do you mean? I find nothing on this anywhere in
Documentation/, and the kerneldoc of hid_hw_stop() does not mention workers.
--
Méven
^ permalink raw reply [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-09-11 13:53 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-11 11:53 [PATCH] HID: logitech-headset: add a battery driver for the PRO X 2 Méven Car
2026-09-11 12:02 ` [PATCH v2] " Méven Car
2026-09-11 12:10 ` sashiko-bot
2026-09-11 13:53 ` Méven
2026-09-11 12:02 ` [PATCH] " sashiko-bot
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox