// SPDX-License-Identifier: GPL-2.0-or-later /* * HID driver for Steelseries arctis headsets * * Copyright (c) 2023 Bastien Nocera * Copyright (c) 2026 Sriman Achanta */ #include #include #include #include #include #include #include #include #include #include "hid-ids.h" #define SS_CAP_BATTERY BIT(0) struct steelseries_device; struct steelseries_device_info { unsigned long capabilities; u8 sync_interface; u8 async_interface; int (*request_status)(struct hid_device *hdev); void (*parse_status)(struct steelseries_device *sd, u8 *data, int size); }; struct steelseries_device { struct kref refcnt; struct hid_device *hdev; const struct steelseries_device_info *info; struct delayed_work status_work; struct power_supply_desc battery_desc; struct power_supply *battery; bool headset_connected; u8 battery_capacity; bool battery_charging; spinlock_t lock; bool removed; }; static void steelseries_device_release(struct kref *ref) { struct steelseries_device *sd = container_of(ref, struct steelseries_device, refcnt); kfree(sd); } /* * Headset report helpers */ static int steelseries_send_report(struct hid_device *hdev, const u8 *data, int len, enum hid_report_type type) { u8 *buf; int ret; buf = kmemdup(data, len, GFP_KERNEL); if (!buf) return -ENOMEM; ret = hid_hw_raw_request(hdev, data[0], buf, len, type, HID_REQ_SET_REPORT); kfree(buf); if (ret < 0) return ret; if (ret < len) return -EIO; return 0; } static inline int steelseries_send_output_report(struct hid_device *hdev, const u8 *data, int len) { return steelseries_send_report(hdev, data, len, HID_OUTPUT_REPORT); } /* * Headset status request functions */ static int steelseries_arctis_1_request_status(struct hid_device *hdev) { const u8 data[] = { 0x06, 0x12 }; return steelseries_send_output_report(hdev, data, sizeof(data)); } static int steelseries_arctis_9_request_status(struct hid_device *hdev) { const u8 data[] = { 0x00, 0x20 }; return steelseries_send_output_report(hdev, data, sizeof(data)); } static int steelseries_arctis_nova_request_status(struct hid_device *hdev) { const u8 data[] = { 0x00, 0xb0 }; return steelseries_send_output_report(hdev, data, sizeof(data)); } /* * Headset battery helpers */ static int battery_capacity_to_level(int capacity) { if (capacity >= 50) return POWER_SUPPLY_CAPACITY_LEVEL_NORMAL; if (capacity >= 20) return POWER_SUPPLY_CAPACITY_LEVEL_LOW; return POWER_SUPPLY_CAPACITY_LEVEL_CRITICAL; } static u8 steelseries_map_capacity(u8 capacity, u8 min_in, u8 max_in) { if (capacity >= max_in) return 100; if (capacity <= min_in) return 0; return (capacity - min_in) * 100 / (max_in - min_in); } /* * Headset status parse functions */ static void steelseries_arctis_1_parse_status(struct steelseries_device *sd, u8 *data, int size) { /* Only the battery status report echoes the request header. */ if (size < 8 || data[0] != 0x06 || data[1] != 0x12) return; sd->headset_connected = (data[2] != 0x01); sd->battery_capacity = data[3]; } static void steelseries_arctis_9_parse_status(struct steelseries_device *sd, u8 *data, int size) { if (size < 5) return; if (data[0] == 0xaa && data[1] == 0x01) { sd->headset_connected = true; sd->battery_charging = (data[4] == 0x01); sd->battery_capacity = steelseries_map_capacity(data[3], 0x64, 0x9a); } else { /* Device off: 0x55 (no status) or 0x03 (stale status). */ sd->headset_connected = false; sd->battery_charging = false; } } static void steelseries_arctis_nova_parse_status(struct steelseries_device *sd, u8 *data, int size) { if (size < 2) return; switch (data[0]) { case 0xb0: if (size < 4) return; sd->headset_connected = (data[1] == 0x03); sd->battery_capacity = data[2]; sd->battery_charging = (data[3] == 0x01); break; case 0xb7: sd->battery_capacity = data[1]; break; case 0xb9: sd->headset_connected = (data[1] == 0x03); break; case 0xbb: sd->battery_charging = (data[1] == 0x01); break; } } static void steelseries_arctis_nova_7_parse_status(struct steelseries_device *sd, u8 *data, int size) { if (size < 2) return; switch (data[0]) { case 0xb0: if (size < 4) return; sd->headset_connected = (data[1] == 0x03); sd->battery_capacity = steelseries_map_capacity(data[2], 0, 4); sd->battery_charging = (data[3] == 0x01); break; case 0xb7: sd->battery_capacity = steelseries_map_capacity(data[1], 0, 4); break; case 0xb9: sd->headset_connected = (data[1] == 0x03); break; case 0xbb: sd->battery_charging = (data[1] == 0x01); break; } } /* * Device info definitions */ static const struct steelseries_device_info arctis_1_info = { .sync_interface = 3, .capabilities = SS_CAP_BATTERY, .request_status = steelseries_arctis_1_request_status, .parse_status = steelseries_arctis_1_parse_status, }; static const struct steelseries_device_info arctis_9_info = { .sync_interface = 0, .capabilities = SS_CAP_BATTERY, .request_status = steelseries_arctis_9_request_status, .parse_status = steelseries_arctis_9_parse_status, }; static const struct steelseries_device_info arctis_nova_info = { .sync_interface = 3, .async_interface = 5, .capabilities = SS_CAP_BATTERY, .request_status = steelseries_arctis_nova_request_status, .parse_status = steelseries_arctis_nova_parse_status, }; static const struct steelseries_device_info arctis_nova_7_info = { .sync_interface = 3, .async_interface = 5, .capabilities = SS_CAP_BATTERY, .request_status = steelseries_arctis_nova_request_status, .parse_status = steelseries_arctis_nova_7_parse_status, }; /* * Headset wireless status and battery infrastructure */ #define STEELSERIES_HEADSET_STATUS_TIMEOUT_MS 3000 static void steelseries_headset_set_wireless_status(struct hid_device *hdev, bool connected) { struct usb_interface *intf; if (!hid_is_usb(hdev)) return; intf = to_usb_interface(hdev->dev.parent); usb_set_wireless_status(intf, connected ? USB_WIRELESS_STATUS_CONNECTED : USB_WIRELESS_STATUS_DISCONNECTED); } #define STEELSERIES_PREFIX "SteelSeries " static int steelseries_battery_get_property(struct power_supply *psy, enum power_supply_property psp, union power_supply_propval *val) { struct steelseries_device *sd = power_supply_get_drvdata(psy); size_t prefix_len; int ret = 0; switch (psp) { case POWER_SUPPLY_PROP_MODEL_NAME: val->strval = sd->hdev->name; while ((prefix_len = str_has_prefix(val->strval, STEELSERIES_PREFIX))) val->strval += prefix_len; break; case POWER_SUPPLY_PROP_MANUFACTURER: val->strval = "SteelSeries"; break; case POWER_SUPPLY_PROP_PRESENT: val->intval = 1; break; case POWER_SUPPLY_PROP_STATUS: if (!sd->headset_connected) val->intval = POWER_SUPPLY_STATUS_UNKNOWN; else if (sd->battery_charging) val->intval = sd->battery_capacity >= 100 ? POWER_SUPPLY_STATUS_FULL : POWER_SUPPLY_STATUS_CHARGING; else val->intval = POWER_SUPPLY_STATUS_DISCHARGING; break; case POWER_SUPPLY_PROP_SCOPE: val->intval = POWER_SUPPLY_SCOPE_DEVICE; break; case POWER_SUPPLY_PROP_CAPACITY: val->intval = sd->battery_capacity; break; case POWER_SUPPLY_PROP_CAPACITY_LEVEL: val->intval = battery_capacity_to_level(sd->battery_capacity); break; default: ret = -EINVAL; break; } return ret; } static enum power_supply_property steelseries_battery_props[] = { POWER_SUPPLY_PROP_MODEL_NAME, POWER_SUPPLY_PROP_MANUFACTURER, POWER_SUPPLY_PROP_PRESENT, POWER_SUPPLY_PROP_STATUS, POWER_SUPPLY_PROP_SCOPE, POWER_SUPPLY_PROP_CAPACITY, POWER_SUPPLY_PROP_CAPACITY_LEVEL, }; /* * Delayed work handlers for status polling */ static void steelseries_status_timer_work_handler(struct work_struct *work) { struct steelseries_device *sd = container_of( work, struct steelseries_device, status_work.work); unsigned long flags; sd->info->request_status(sd->hdev); spin_lock_irqsave(&sd->lock, flags); /* Async devices push status events themselves; only poll once. */ if (!sd->removed && !sd->info->async_interface) schedule_delayed_work(&sd->status_work, msecs_to_jiffies(STEELSERIES_HEADSET_STATUS_TIMEOUT_MS)); spin_unlock_irqrestore(&sd->lock, flags); } static int steelseries_battery_register(struct steelseries_device *sd) { struct power_supply_config battery_cfg = { .drv_data = sd, }; struct power_supply *battery; int ret; sd->battery_desc.type = POWER_SUPPLY_TYPE_BATTERY; sd->battery_desc.properties = steelseries_battery_props; sd->battery_desc.num_properties = ARRAY_SIZE(steelseries_battery_props); sd->battery_desc.get_property = steelseries_battery_get_property; sd->battery_desc.use_for_apm = 0; sd->battery_desc.name = devm_kasprintf(&sd->hdev->dev, GFP_KERNEL, "steelseries_headset_battery_%s", sd->hdev->uniq[0] ? sd->hdev->uniq : dev_name(&sd->hdev->dev)); if (!sd->battery_desc.name) return -ENOMEM; /* avoid the warning of 0% battery while waiting for the first info */ sd->battery_capacity = 100; sd->battery_charging = false; sd->headset_connected = false; steelseries_headset_set_wireless_status(sd->hdev, false); battery = power_supply_register(&sd->hdev->dev, &sd->battery_desc, &battery_cfg); if (IS_ERR(battery)) { ret = PTR_ERR(battery); hid_err(sd->hdev, "%s:power_supply_register failed with error %d\n", __func__, ret); return ret; } power_supply_powers(battery, &sd->hdev->dev); /* Assign on success only, so a concurrent raw_event never sees an ERR_PTR. */ sd->battery = battery; return 0; } static struct hid_driver steelseries_arctis_driver; static struct steelseries_device * steelseries_get_sibling_sd(struct hid_device *hdev, int interface_num) { struct usb_interface *intf = to_usb_interface(hdev->dev.parent); struct usb_device *usb_dev = interface_to_usbdev(intf); struct usb_interface *sibling_intf; struct hid_device *sibling_hdev; struct steelseries_device *sd = NULL; sibling_intf = usb_ifnum_to_if(usb_dev, interface_num); if (!sibling_intf) return NULL; /* * usb_get_intfdata() only yields a hid_device when usbhid is bound; * gate on the descriptor class so a non-HID sibling (e.g. a crafted * device exposing storage or audio here) is never treated as one. */ if (sibling_intf->cur_altsetting->desc.bInterfaceClass != USB_INTERFACE_CLASS_HID) return NULL; /* * Take the sibling's device lock across the intfdata read and the * kref_get so a concurrent unbind cannot free the hid_device underneath * us; usbhid leaves intfdata dangling on disconnect, so dev.driver is * the reliable "still bound" test under this lock. Use device_trylock() * to stay off the lockdep chain of the interface being probed and let * the caller retry via -EPROBE_DEFER if the sibling is momentarily busy. */ if (!device_trylock(&sibling_intf->dev)) return NULL; if (sibling_intf->dev.driver) { sibling_hdev = usb_get_intfdata(sibling_intf); if (sibling_hdev && sibling_hdev->driver == &steelseries_arctis_driver) { sd = hid_get_drvdata(sibling_hdev); if (sd) kref_get(&sd->refcnt); } } device_unlock(&sibling_intf->dev); return sd; } static int steelseries_arctis_probe(struct hid_device *hdev, const struct hid_device_id *id) { const struct steelseries_device_info *info = (const struct steelseries_device_info *)id->driver_data; struct steelseries_device *sd; struct usb_interface *intf; u8 interface_num; int ret; if (hid_is_usb(hdev)) { intf = to_usb_interface(hdev->dev.parent); interface_num = intf->cur_altsetting->desc.bInterfaceNumber; } else { return -ENODEV; } ret = hid_parse(hdev); if (ret) return ret; /* Let hid-generic handle non-vendor or unknown interfaces */ if (interface_num != info->sync_interface && (!info->async_interface || interface_num != info->async_interface)) return hid_hw_start(hdev, HID_CONNECT_DEFAULT); if (interface_num == info->sync_interface) { sd = kzalloc_obj(*sd, GFP_KERNEL); if (!sd) return -ENOMEM; kref_init(&sd->refcnt); sd->hdev = hdev; sd->info = info; spin_lock_init(&sd->lock); INIT_DELAYED_WORK(&sd->status_work, steelseries_status_timer_work_handler); ret = hid_hw_start(hdev, HID_CONNECT_DEFAULT); if (ret) goto err_free; ret = hid_hw_open(hdev); if (ret) goto err_stop; if (info->capabilities & SS_CAP_BATTERY) { ret = steelseries_battery_register(sd); if (ret < 0) hid_warn(hdev, "Failed to register battery: %d\n", ret); } /* * Publish drvdata only once fully initialised: the async sibling * attaches by reading it, so it must never observe a half-built or * failed instance. A failed probe never gets here, so the error * path below has nothing to unpublish. */ hid_set_drvdata(hdev, sd); schedule_delayed_work(&sd->status_work, msecs_to_jiffies(100)); return 0; } /* * The async interface shares the steelseries_device created by the * sync interface. Defer until the sync interface has probed and * published its drvdata. */ if (info->async_interface && interface_num == info->async_interface) { sd = steelseries_get_sibling_sd(hdev, info->sync_interface); if (!sd) return -EPROBE_DEFER; hid_set_drvdata(hdev, sd); ret = hid_hw_start(hdev, HID_CONNECT_DEFAULT); if (ret) { kref_put(&sd->refcnt, steelseries_device_release); return ret; } ret = hid_hw_open(hdev); if (ret) { hid_hw_stop(hdev); kref_put(&sd->refcnt, steelseries_device_release); return ret; } return 0; } return -ENODEV; err_stop: hid_hw_stop(hdev); err_free: /* drvdata is unpublished until full success, so no sibling can hold sd. */ kref_put(&sd->refcnt, steelseries_device_release); return ret; } static void steelseries_arctis_remove(struct hid_device *hdev) { struct steelseries_device *sd; struct power_supply *battery; unsigned long flags; struct usb_interface *intf; u8 interface_num; if (hid_is_usb(hdev)) { intf = to_usb_interface(hdev->dev.parent); interface_num = intf->cur_altsetting->desc.bInterfaceNumber; } else { return; } sd = hid_get_drvdata(hdev); if (!sd) { hid_hw_stop(hdev); return; } if (interface_num == sd->info->sync_interface) { spin_lock_irqsave(&sd->lock, flags); sd->removed = true; battery = sd->battery; sd->battery = NULL; spin_unlock_irqrestore(&sd->lock, flags); cancel_delayed_work_sync(&sd->status_work); if (battery) power_supply_unregister(battery); } hid_hw_close(hdev); hid_hw_stop(hdev); kref_put(&sd->refcnt, steelseries_device_release); } static int steelseries_arctis_raw_event(struct hid_device *hdev, struct hid_report *report, u8 *data, int size) { struct steelseries_device *sd = hid_get_drvdata(hdev); u8 old_capacity; bool old_connected; bool old_charging; bool is_async_interface; unsigned long flags; if (!sd) return 0; is_async_interface = (hdev != sd->hdev); spin_lock_irqsave(&sd->lock, flags); if (sd->removed) { spin_unlock_irqrestore(&sd->lock, flags); return 0; } old_capacity = sd->battery_capacity; old_connected = sd->headset_connected; old_charging = sd->battery_charging; sd->info->parse_status(sd, data, size); if (sd->headset_connected != old_connected) { hid_dbg(hdev, "Connected status changed from %sconnected to %sconnected\n", old_connected ? "" : "not ", sd->headset_connected ? "" : "not "); if (sd->headset_connected && !old_connected && sd->info->async_interface && is_async_interface) schedule_delayed_work(&sd->status_work, 0); if (sd->battery) { steelseries_headset_set_wireless_status(sd->hdev, sd->headset_connected); power_supply_changed(sd->battery); } } if (sd->battery_capacity != old_capacity) { hid_dbg(hdev, "Battery capacity changed from %d%% to %d%%\n", old_capacity, sd->battery_capacity); if (sd->battery) power_supply_changed(sd->battery); } if (sd->battery_charging != old_charging) { hid_dbg(hdev, "Battery charging status changed from %scharging to %scharging\n", old_charging ? "" : "not ", sd->battery_charging ? "" : "not "); if (sd->battery) power_supply_changed(sd->battery); } spin_unlock_irqrestore(&sd->lock, flags); return 0; } static const struct hid_device_id steelseries_arctis_devices[] = { { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_1_X), .driver_data = (unsigned long)&arctis_1_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_9), .driver_data = (unsigned long)&arctis_9_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_5_X), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7), .driver_data = (unsigned long)&arctis_nova_7_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X), .driver_data = (unsigned long)&arctis_nova_7_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X_2), .driver_data = (unsigned long)&arctis_nova_7_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_DIABLO), .driver_data = (unsigned long)&arctis_nova_7_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_WOW), .driver_data = (unsigned long)&arctis_nova_7_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_2026), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_P_2026), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X_2026), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_DIABLO_2026), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_GEN2), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X_GEN2), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X_GEN2_2), .driver_data = (unsigned long)&arctis_nova_info }, { HID_USB_DEVICE(USB_VENDOR_ID_STEELSERIES, USB_DEVICE_ID_STEELSERIES_ARCTIS_NOVA_7_X_GEN2_3), .driver_data = (unsigned long)&arctis_nova_info }, {} }; MODULE_DEVICE_TABLE(hid, steelseries_arctis_devices); static struct hid_driver steelseries_arctis_driver = { .name = "hid-steelseries-arctis", .id_table = steelseries_arctis_devices, .probe = steelseries_arctis_probe, .remove = steelseries_arctis_remove, .raw_event = steelseries_arctis_raw_event, }; module_hid_driver(steelseries_arctis_driver); MODULE_DESCRIPTION("HID driver for Steelseries arctis headsets"); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Christian Mayer "); MODULE_AUTHOR("Bastien Nocera "); MODULE_AUTHOR("Sriman Achanta ");