Skip to content

HID: ayaneo: Add AYANEO 3 detachable controller driver - #3

Merged
NeroReflex merged 2 commits into
OpenGamingCollective:masterfrom
matmartinez:hid-ayaneo-unstable
Aug 24, 2026
Merged

HID: ayaneo: Add AYANEO 3 detachable controller driver#3
NeroReflex merged 2 commits into
OpenGamingCollective:masterfrom
matmartinez:hid-ayaneo-unstable

Conversation

@matmartinez

Copy link
Copy Markdown

Submitting here for review prior to LKML, per @pastaq in ShadowBlip/OpenGamepadUI#528.

Same commit as OpenGamingCollective/linux#101 (rebased onto this master; happy to close whichever of the two is redundant — guidance welcome on how these flow together).

What it does

Driver for the AYANEO 3 detachable controller ("Magic Modules") vendor HID interface (1c4f:0002, application usage 0xff000001; DMI-gated to the AYANEO 3 since the VID/PID is a generic SigmaMicro ID):

  • module_left / module_right sysfs attrs — raw firmware module-type IDs (bits 0–5 type, bit 6 rotated)
  • eject sysfs attr (left/right/both) — blocks until the firmware confirms the release handshake
  • reset — quick controller config reset
  • multicolor LED class device ayaneo:rgb:joystick_rings (the name InputPlumber's 50-ayaneo_3.yaml already expects)

EC power-off is deliberately left to userspace (write 0 to ayaneo-ec's controller_power after eject returns) so orchestration/UX stays in the OpenGamepadUI layer. Protocol reverse engineered in Handheld Daemon by Antheas Kapenekakis (he'll be CC'd on the LKML series). Includes Documentation/ABI/ and MAINTAINERS entries; checkpatch --strict clean except the standard -ENOSYS output-report-fallback false positive.

Testing

On an AYANEO 3 / Bazzite 44 (OGC 7.2.0-ogc4.1): probe identifies modules (left 0x04 right 0x50), RGB via LED class verified, and full physical eject → power-off → release → reinsert → repower → re-enumeration → rebind cycles, both from the shell and driven by a working OpenGamepadUI quick-bar plugin (see ShadowBlip/OpenGamepadUI#528). Community testing guide: https://github.com/matmartinez/ayaneo-3-bazzite-compat/blob/main/TESTING.md

Comment thread drivers/hid/hid-ayaneo.c Outdated
/* Input reports are not delivered during probe by default */
hid_device_io_start(hdev);

mutex_lock(&aya->lock);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here I would use scoped_guard to spare the line of mutex_unlock

Comment thread drivers/hid/hid-ayaneo.c Outdated
if (ret)
hid_warn(hdev, "controller did not answer status check: %d\n",
ret);
else

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kernel practice is not to print anything when things go as planned

Comment thread MAINTAINERS
F: drivers/spi/spi-axiado.c
F: drivers/spi/spi-axiado.h

AYANEO 3 CONTROLLER HID DRIVER

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have rarely seen hid devices requiring an entry in MAINTAINERS, are you sure?

Comment thread drivers/hid/hid-ayaneo.c Outdated
return 0;
}

/* Send the command in aya->xfer and wait for the echoing reply. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function deserves a kernel-doc also explaining arguments and the locking

Comment thread drivers/hid/hid-ayaneo.c Outdated

static void aya3_checksum(u8 *buf)
{
unsigned int sum = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personally I would use a fixed-width type here, like u32 or u64 depending on if the result would fit the u32.... Maybe a u16 can also work? Especially since you then use put_unaligned_le16

Comment thread drivers/hid/hid-ayaneo.c
return ret;
}

static void aya3_remove(struct hid_device *hdev)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can guarantee you sahiko-bot is going to cry over this with a bunch of "what if user uses sysfs attributes while a remove is started?"

Comment thread drivers/hid/hid-ayaneo.c Outdated
static int aya3_send_config(struct aya3 *aya, u8 eject)
{
u8 *buf = aya->xfer;
u8 mode = AYA3_RGB_SOLID;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would do here u8 mode = (led_on_condition) ? AYA3_RGB_SOLID : AYA3_RGB_OFF; and spare the next two lines.

Comment thread drivers/hid/hid-ayaneo.c Outdated
if (!aya->rgb[0] && !aya->rgb[1] && !aya->rgb[2])
mode = AYA3_RGB_OFF;

memset(buf, 0, AYA3_REPORT_SIZE);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would do it the other way around: create a const u8 buf[SIZE] = {}; that will be zero-filled automatically on unspecified elements and then copy that to the dma buffer.

Comment thread drivers/hid/hid-ayaneo.c
for (i = 0; i < 3; i++)
aya->rgb[i] = min_t(unsigned int, aya->subleds[i].brightness, 255);

ret = aya3_send_config(aya, 0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since on error IDK what happens I would use a hid_err here in case ret has unexpected values

Comment thread drivers/hid/hid-ayaneo.c Outdated
aya->mcled.subled_info = aya->subleds;
aya->mcled.num_colors = 3;

cdev->name = "ayaneo:rgb:joystick_rings";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sahiko-bot is going to complain about the name with a "what if an aya3 spoofed device is being emulated?". I would suggest doing what hid-asus does and compose this name with a dynamic part.

Comment thread drivers/hid/hid-ayaneo.c
@matmartinez

Copy link
Copy Markdown
Author

Thanks for the thorough review @NeroReflex! All addressed, I pushed each point as a separate commit for easy re-review (I'll squash everything back into the single patch before this goes to LKML):

  • scoped_guard in probe → done.
  • No print on success → done; dropped the hid_info() and the now-unused response buffer, kept only the warning path.
  • kernel-doc for aya3_cmd() → done; documents the arguments, the subcommand-echo reply matching, and the locking.
  • Fixed-width checksum → done; u16 — the sum of the 58 payload bytes maxes out at 14790, and the device consumes it as LE16 anyway.
  • Ternary for the RGB mode → done.
  • Zero-filled template for the config command → done; static const u8 template[] with designated initializers, memcpy'd into the DMA buffer, then only the dynamic fields (RGB, eject, vibration, checksum) are filled in.
  • hid_err on LED update failure → done.
  • Dynamic LED name → done; devm_kasprintf("%s:rgb:joystick_rings", dev_name(&hdev->dev)). I checked the userspace side: InputPlumber matches sys_name as a glob, so its 50-ayaneo_3.yaml just needs a one-liner to *:rgb:joystick_rings once this lands (its config is DMI-gated to the AYANEO 3, so the glob is safe). I'll send that PR when the driver is merged.
  • USB parent check → as you noted, hid_is_usb() a few lines down covers it.

I think the current code works well in two spots, let me know if you see it differently:

  • sysfs vs remove() race: the driver core removes driver->dev_groups before calling remove() (device_remove() in drivers/base/dd.c), and kernfs holds an active reference for in-flight show/store calls, so unbind waits for them to drain. A store racing with unbind either completes before hid_hw_stop() or never starts.
  • MAINTAINERS entry: per-driver HID entries do exist where someone volunteers as maintainer (HID PHOENIX RC, HID WACOM, HID++ LOGITECH, …), and the entry also covers the ABI doc file. I'm committing to maintain this, so I'd keep it, but I'll defer to whatever the hid maintainers say on the list, and can drop it here if you prefer.

checkpatch --strict is clean on all the new commits, and I re-tested the updated driver on my AYANEO 3: probe is now silent, module type reads work, and the renamed LED sets/clears the joystick rings correctly (which also exercises the template-built config command end-to-end, since the device has to ACK it).

@NeroReflex

NeroReflex commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the thorough review @NeroReflex! All addressed, I pushed each point as a separate commit for easy re-review (I'll squash everything back into the single patch before this goes to LKML):

  • scoped_guard in probe → done.
  • No print on success → done; dropped the hid_info() and the now-unused response buffer, kept only the warning path.
  • kernel-doc for aya3_cmd() → done; documents the arguments, the subcommand-echo reply matching, and the locking.
  • Fixed-width checksum → done; u16 — the sum of the 58 payload bytes maxes out at 14790, and the device consumes it as LE16 anyway.
  • Ternary for the RGB mode → done.
  • Zero-filled template for the config command → done; static const u8 template[] with designated initializers, memcpy'd into the DMA buffer, then only the dynamic fields (RGB, eject, vibration, checksum) are filled in.

I'm not sure adding the static is good idea: first time it will get populated and later on not touched... Are you sure driver still works? Beside it adds to the .bss without any real reason. I thing const is enough here.

  • hid_err on LED update failure → done.
  • Dynamic LED name → done; devm_kasprintf("%s:rgb:joystick_rings", dev_name(&hdev->dev)). I checked the userspace side: InputPlumber matches sys_name as a glob, so its 50-ayaneo_3.yaml just needs a one-liner to *:rgb:joystick_rings once this lands (its config is DMI-gated to the AYANEO 3, so the glob is safe). I'll send that PR when the driver is merged.

sashiko-bot will tell you: what if dev_name(...) is NULL?

  • USB parent check → as you noted, hid_is_usb() a few lines down covers it.

Maybe it can be moved above so that the kernel doesn't even try reading the descriptor if it's not a USB?

I think the current code works well in two spots, let me know if you see it differently:

  • sysfs vs remove() race: the driver core removes driver->dev_groups before calling remove() (device_remove() in drivers/base/dd.c), and kernfs holds an active reference for in-flight show/store calls, so unbind waits for them to drain. A store racing with unbind either completes before hid_hw_stop() or never starts.
  • MAINTAINERS entry: per-driver HID entries do exist where someone volunteers as maintainer (HID PHOENIX RC, HID WACOM, HID++ LOGITECH, …), and the entry also covers the ABI doc file. I'm committing to maintain this, so I'd keep it, but I'll defer to whatever the hid maintainers say on the list, and can drop it here if you prefer.
    Splendid.

checkpatch --strict is clean on all the new commits, and I re-tested the updated driver on my AYANEO 3: probe is now silent, module type reads work, and the renamed LED sets/clears the joystick rings correctly (which also exercises the template-built config command end-to-end, since the device has to ACK it).

@NeroReflex

Copy link
Copy Markdown
Collaborator

You should merge every modification into the single patch, then do a [NOT-FOR-UPSTREAM] patch that adds to the fragment in this repo the CONFIG_AYANEO required to build the driver so that the github workflow can compile the driver.

The AYANEO 3 handheld has a detachable controller with swappable
modules ("Magic Modules"). The controller exposes three USB HID
interfaces behind 1c4f:0002 (a generic SigmaMicro VID/PID, hence the
DMI gate): a gamepad, a keyboard for the extra buttons, and a vendor
interface accepting 65-byte commands.

Add a driver for the vendor interface providing module identification
(module_left/module_right sysfs attributes), software eject of the
modules (eject sysfs attribute, blocking until the firmware confirms
the release handshake), and RGB control of the joystick rings as a
multicolor LED class device named ayaneo:rgb:joystick_rings, matching
the name InputPlumber already expects for this device.

This complements the ayaneo-ec platform driver, which exposes module
attach state and controller power. A full physical eject is performed
by writing to eject and then cutting power through ayaneo-ec's
controller_power attribute; that orchestration is deliberately left
to userspace.

The protocol was reverse engineered in the Handheld Daemon project by
Antheas Kapenekakis. Tested on an AYANEO 3 (7.2.0-ogc4.1): module
identification, RGB, and a full eject/reinsert/repower cycle.

Signed-off-by: Matías Martínez <hello@matias.me>
Lets the build workflow compile the new driver. The real OGC config
change is OpenGamingCollective/kernel-packages#35, which lands once the
driver merges.

Signed-off-by: Matías Martínez <hello@matias.me>
@matmartinez

Copy link
Copy Markdown
Author

@NeroReflex Thank you!

Done on both process points: everything is squashed back into the single [FOR-UPSTREAM] patch, and there's now a [NOT-FOR-UPSTREAM] commit adding CONFIG_HID_AYANEO=m to .github/packaging/config.fragment (noted as mirroring kernel-packages#35, which stays the real config change once the driver merges).

On the three code points:

  • hid_is_usb() before parsing → done, moved above hid_parse() so non-USB transports bail before the descriptor is even parsed. Rebuilt and re-tested on the device.
  • static const template: it doesn't behave like a runtime-populated static — static const with an initializer is materialized at compile time into .rodata (.bss only holds zero-initialized writable data), so there's no "first time it gets populated" and nothing can touch it afterwards; the section is read-only. Dropping static would actually make it worse: a non-static const local is constructed on the stack on every call, only to be memcpy'd right after. And yes — re-verified on hardware: the device ACKs the template-built config command and the RGB rings respond, so the bytes coming out are correct.
  • dev_name() NULL: it can't be NULL by the time probe runs — HID core names the device in hid_add_device() (dev_set_name(&hdev->dev, "%04X:%04X:%04X.%04X", ...)) before the device is ever offered to a driver, and probe can only run on a registered device. The allocation that can fail is devm_kasprintf() itself, and that return is checked.

Current state: checkpatch --strict on the squashed patch reports only the well-known ENOSYS false positive (hid_hw_output_report() returns -ENOSYS by contract when the transport has no output-report support; the fallback-to-SET_REPORT pattern is standard across drivers/hid/).

I think the earlier CI failure was indeed the config gate flagging the missing CONFIG_HID_AYANEO, the fragment commit should make it pass. The new run needs workflow approval whenever you get a chance 🙏

@NeroReflex

Copy link
Copy Markdown
Collaborator

Perfect, thank you. As soon as CI compiles the driver I will merge

@NeroReflex
NeroReflex merged commit 5c32c7e into OpenGamingCollective:master Aug 24, 2026
2 checks passed
@NeroReflex

Copy link
Copy Markdown
Collaborator

I asked claude to review one of my drivers. It has said this, I will paste it because I think it's useful to you too.

Feel free to start from a HEAD prior to my merge and reopen another PR. I will take care of the rest.

Good find — and this one's subtler than a simple missing check. hid_is_usb(hdev) only inspects hdev->bus:

static inline bool hid_is_usb(const struct hid_device *hdev)
{
	return hdev->bus == BUS_USB;
}

That field is attacker-controlled: any unprivileged process with access to /dev/uhid can issue UHID_CREATE and set bus = BUS_USB while the actual hdev->dev.parent is the uhid virtual device, not a struct usb_interface. So hid_is_usb(hdev) returning true does not guarantee hdev->dev.parent is safe to cast with to_usb_interface()/interface_to_usbdev(). This affects every USB-cast site in the driver gated only by hid_is_usb(), not just the one hunk the bot flagged in hid_asus_ally_probe() — ally_get_endpoint_address(), asus_kbd_register_leds(), and the QUIRK_T100_KEYBOARD/QUIRK_MEDION_E1239T branches in asus_probe() all have the same gap.

The fix: verify the parent device is actually attached to the USB bus (dev->bus == &usb_bus_type) before trusting the cast, not just the spoofable hdev->bus field.

+/*
+ * hid_is_usb() only checks hdev->bus, which is attacker-controlled by any
+ * process with access to /dev/uhid: UHID_CREATE lets userspace claim an
+ * arbitrary bus id, including BUS_USB, while hdev->dev.parent is the uhid
+ * virtual device, not a struct usb_interface. Casting dev.parent based on
+ * hid_is_usb() alone lets such a spoofed "USB" HID device redirect the
+ * cast at unrelated memory. Confirm the parent is actually on the USB bus
+ * before trusting the cast.
+ */
+static bool asus_hdev_is_usb(struct hid_device *hdev)
+{
+	return hid_is_usb(hdev) && hdev->dev.parent &&
+	       hdev->dev.parent->bus == &usb_bus_type;
+}
+
 static int ally_get_endpoint_address(struct hid_device *hdev)
 {
 	struct usb_host_endpoint *ep;
 	struct usb_interface *intf;
 
-	if (!hid_is_usb(hdev))
+	if (!asus_hdev_is_usb(hdev))
 		return -ENODEV;
 
 	intf = to_usb_interface(hdev->dev.parent);

@NeroReflex

Copy link
Copy Markdown
Collaborator

@matmartinez

Copy link
Copy Markdown
Author

@NeroReflex Thanks for the merge and the fast review cycle!

For completeness I checked hid-ayaneo against the underlying concern anyway: the driver never casts hdev->dev.parent (no to_usb_interface() anywhere — everything goes through hid_hw_output_report()/hid_hw_raw_request()), so there was no unsafe dereference to guard even under the stale definition.

Next on my side; the InputPlumber sys_name glob one-liner for the renamed LED, then the upstream series!

@NeroReflex

Copy link
Copy Markdown
Collaborator

When you send it upstream please include

Reviewed-by: Denis Benato <denis.benato@linux.dev>

And send to me too please.

@matmartinez

Copy link
Copy Markdown
Author

Done! Submitted to linux-input/LKML with your Reviewed-by in the trailers and you on Cc, alongside Antheas:

https://lore.kernel.org/linux-input/20260824215041.79892-1-hello@matias.me/

Rebased onto hid.git for-next (applied cleanly, base-commit included) with one commit-message fix: the LED-name paragraph now describes the per-device <device name>:rgb:joystick_rings naming from your review instead of the old fixed name. Matching InputPlumber change is up as ShadowBlip/InputPlumber#666.

Thanks again for the review and the merge <3 I'll follow up here if the upstream review produces changes worth backporting to the OGC tree.

@NeroReflex

Copy link
Copy Markdown
Collaborator

Done! Submitted to linux-input/LKML with your Reviewed-by in the trailers and you on Cc, alongside Antheas:

https://lore.kernel.org/linux-input/20260824215041.79892-1-hello@matias.me/

Rebased onto hid.git for-next (applied cleanly, base-commit included) with one commit-message fix: the LED-name paragraph now describes the per-device <device name>:rgb:joystick_rings naming from your review instead of the old fixed name. Matching InputPlumber change is up as ShadowBlip/InputPlumber#666.

Thanks again for the review and the merge <3 I'll follow up here if the upstream review produces changes worth backporting to the OGC tree.

Now we wait for sashiko-bot XD

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants