Skip to content

HID: haptics for the Apple SPI trackpad - #4

Open
Chromatischer wants to merge 13 commits into
aurora-silicon:aurora-wipfrom
Chromatischer:feature/apple-mtp-haptics
Open

Chromatischer wants to merge 13 commits into
aurora-silicon:aurora-wipfrom
Chromatischer:feature/apple-mtp-haptics

Conversation

@Chromatischer

Copy link
Copy Markdown

The taptic engine on SPI machines (M1, J313) was out of reach: the transport
capped HID interfaces at three, so the actuator was never enumerated, and it
addressed reports by interface id rather than report id. Both fixed here, then
the click is made adjustable — per-transport waveform defaults, optional
host-driven feedback, and a deep click derived from finger pressure.

Three commits are Yicong Hui's original MTP actuator work, carried with his
authorship.

Tested on a J313. Host-driven feedback is off by default and hands the actuator
back to the firmware on any failure, so a pad that cannot play a waveform keeps
working feedback rather than none.

hex-64 and others added 10 commits September 16, 2026 23:24
dchid_set_report() always sends HID_OUTPUT_REPORT regardless of the
report type requested by the caller. Inline it into its sole caller
and forward rtype directly, so SET_REPORT works correctly for feature
reports too, not just output reports, and to simplify function calls.

Signed-off-by: Yicong Hui <yicong@ycstuff.net>
The J313 trackpad firmware reports five HID interfaces, but the driver
clamps the count to three and only ever queries mngt, kbd and tp. The
fourth one is the trackpad's haptic actuator:

    iface 3: name 'Actuator', country 0x0, in 16 out 64 ctrl 1024

Its report descriptor declares output report 0x53, which is the report a
waveform is played with, so dropping the interface puts the taptic engine
out of reach entirely.

Raise the cap to four and give interface 3 a spihid_interface of its own
so that it is queried, registered as a HID device and torn down with the
others. The "expecting at least 3" sanity check tested against the same
constant, so give it its own SPIHID_MIN_DEVICES; otherwise raising the cap
would reject a machine that reports exactly three interfaces.

Interface 4 is still not enumerated. What it is has not been established.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
apple_ll_raw_request() refused any SET_REPORT whose report number differed
from the interface id, and apple_ll_output_report() tagged its request with
the interface id rather than the report id in buf[0]. The comment on the
latter already suspected it: "second idev->id should maybe be buf[0]?".

Both survived only because the report number and the interface id happened
to coincide for every report the driver sent: the keyboard's LED report is
report 1 on interface 1, and the SPI trackpad's multitouch enable is report
2 on interface 2.

The actuator on interface 3 takes reports 0x21 and 0x53, so neither held.
Its mode switch was rejected before it was sent,

    spi-hid-apple-of spi2.0: device:3 reportnum:33 mismatch

and its waveforms went out labelled report 3, which the firmware ignored
silently: every write was accepted and nothing moved.

With both corrected the actuator latches host-controlled mode, feature
report 0x21 reads back 01, and waveforms play on a J313.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
Add a driver to control the actuator of the MTP trackpad, for M2 and
later devices. It plays a waveform with output report 0x53 and switches
the actuator between device-controlled and host-controlled mode with
feature report 0x21.

The specific hardware commands and their fields have been reverse
engineered on my own machine (Macbook Pro 2023 M2 Max) using the m1n1
hypervisor.

Signed-off-by: Yicong Hui <yicong@ycstuff.net>
Co-developed-by: Chromatischer <dominik@hildania.de>
Signed-off-by: Chromatischer <dominik@hildania.de>
The actuator on the SPI machines takes the same output report 0x53 and the
same mode-switch feature report 0x21 as the MTP one, so the driver needs no
protocol change to drive it; it simply never matched. Its id table covers
BUS_HOST only, and its .match insists on the name "Apple MTP actuator",
while spi-hid names the interface "Apple SPI Actuator".

Nothing else claims the device either: hid-apple's id table matches every
Apple SPI device, which makes hid_generic_match() stand aside, and
hid-apple's probe then rejects anything that is not HID_TYPE_SPI_KEYBOARD.
The actuator is left bound to no driver at all.

Match BUS_SPI Apple devices as well and accept either name, via a helper in
the actuator header so hid-magicmouse can use the same test. Start with
HID_CONNECT_HIDRAW so the waveform parameters can be exercised from
userspace: the strength and softness that suit this engine are not known
yet, and the MTP defaults feel harsh on a J313.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
Integrate the MTP actuator driver with the MTP trackpad driver to expose
new functionality that allows userspace to control the haptic effects
of the touchpad actuator as a force feedback device, following the
convention for haptic touchpad functionality as specified in [1].

Set the INPUT_PROP_PRESSUREPAD property within the MTP trackpad device
indicating that this device is a haptic touchpad, and set the FF_HAPTIC
capability to indicate that force feedback haptic effects can be
uploaded to the trackpad [2]. Userspace can thus choose to upload force
feedback effects to the touchpad, which would trigger it to switch into
"Host controlled" mode, where the touchpad will no longer click unless
signalled to do so by userspace.

This patch uses an unconventional means of doing so, by writing a
separate loadable module for the haptic actuator. This is because
the haptic actuator is considered a separate device on MTP trackpads,
whereas haptics for HUTRR63 (and the other kernel drivers) considers the
haptic actuator and the touchpad to be the very same device. Therefore,
to conform to the standardized interface in [1] this patch creates a
separate driver for the separate HID device, and is designed such that
commands to the actuator are sent through the touchpad.

This changeset is relevant because allowing userspace control of the
haptics allows for user control of:
    1. Click feedback intensity
    2. Sensitivity of when clicks trigger
    3. Configuring "Force Touch"-like deep click functionality

The actuator is a sibling HID device that the transport can register
after the trackpad, so it is resolved lazily on the first effect upload.
The force feedback device is created before the input device is
registered and destroyed explicitly before the driver state goes away,
and the whole integration is compiled out when the actuator driver is
not reachable, so this driver does not depend on it.

[1] https://lore.kernel.org/all/20250818-support-forcepads-v3-0-e4f9ab0add84@google.com/T/#m12f9be6691015fb9b2e834248c368e5f90
[2] https://docs.kernel.org/input/event-codes.html#input-prop-pressurepad

Signed-off-by: Yicong Hui <yicong@ycstuff.net>
Co-developed-by: Chromatischer <dominik@hildania.de>
Signed-off-by: Chromatischer <dominik@hildania.de>
magicmouse_init_haptics() requires BUS_HOST, and match_actuator() looks for
the MTP actuator by name, so the SPI trackpads were left without haptics
even though their machines have an actuator that speaks the same protocol.

Accept BUS_SPI as well and use the shared name test. The lookup itself
needs no change: the actuator is a sibling HID device under the same SPI
parent, which is what magicmouse_get_actuator() already searches.

This does not change how a click feels by default. The actuator stays in
device-controlled mode, where its firmware provides the click feedback, and
only moves to host-controlled mode when userspace uploads an effect.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
The waveform sent to the actuator carries a strength and a softness. Both
were fixed: softness at 0x90, and strength at the full 255 scaled by the
effect's intensity. Those suit the MTP actuator, but on a J313 the same
numbers produce a hard knock rather than a click. Judged by feel there, a
press wants roughly strength 0x28 at softness 0xf0, far weaker than the
MTP default, while a deep click wants full strength 0xff at a softness of
0xd0.

Add four parameters for the two waveforms, each defaulting per transport so
the MTP path keeps the values its firmware was tuned with. Strength now
names what 100% intensity maps to, so an effect asking for half intensity
still gets half of it.

They are writable at runtime and take effect on the next effect upload,
which is what makes tuning by feel practical: the values that suit a given
actuator cannot be derived, only tried.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
The actuator's firmware provides the feedback for a physical press, and how
that press feels cannot be changed while it does. Playing the waveform from
the host instead makes it adjustable, but it cannot be done by reacting to
the button the trackpad reports: in host-controlled mode the firmware stops
detecting clicks altogether. It keeps reporting fingers and never sets the
button again, because deciding that a press happened belongs to whoever
owns the actuator. Hooking the button bit therefore works exactly once —
for the press already in flight when the mode changed — and then the pad
has no click feedback and no button at all.

So derive the click here. Sum the pressure of the fingers in each frame,
call a press when it rises past haptic_press_threshold and a release when
it falls below haptic_release_threshold, report BTN_MOUSE from that, and
play the waveform from the haptics workqueue, which is where it can sleep.

The thresholds default to 120 and 75, measured on a J313 against its own
firmware: it asserts the button between 130 and 243 and releases between 26
and 73. The gap between the two is what stops a click chattering.

This is off by default. Turning it off returns the actuator to the firmware
on the next frame, and any failure while playing does the same, so a pad
that cannot play a waveform falls back to working feedback rather than
none.

What the parameters do on an SPI actuator is narrower than the report
layout suggests. The fifteen waveform bytes are ignored: zeroing them feels
identical. Strength is close to on or off, with 0x00 silent and the values
above it differing little. Softness is what shapes the pulse; swept from
0x10 to 0xff at a fixed strength it runs from a faint tick to a hard knock.
A click that wants more weight than softness gives is made of more pulses,
which is what haptic_press_pulses is for.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
The pad reports finger pressure well past the point where a click is
asserted, so a second, firmer press inside one click can be told apart
from an ordinary one. That is what Force Touch does, and the hardware
already carries the signal.

Fire once per click at haptic_deep_threshold, with its own pulse count,
gap and waveform shape so it can be made to feel distinct from the first
click, and clear the flag only on release so holding at depth does not
repeat it. Linux has no key for a force click, so haptic_deep_keycode
names one and nothing is reported unless it is set; the capability has
to be advertised at probe, so that parameter is read-only.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
Chromatischer added 3 commits September 17, 2026 10:14
The firmware reports the button only while it owns the actuator. The
derived click covered haptic_press, but a force-feedback effect uploaded
by userspace also takes host-controlled mode, and the mode cannot be
released while an effect is loaded. In that state the driver reported the
firmware's button, which never changes again, so the pad stopped clicking
until every effect was erased.

Derive the button whenever the actuator is in host-controlled mode,
whatever put it there, and keep asking for it back when the waveform is
not ours to play.

Found by a review bot on the pull request.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
The actuator leaves system sleep in device-controlled mode. The transport
resumes the keyboard and the trackpad, not the actuator, and nothing tells
the driver the mode changed, so msc->haptics->mode still reads
host-controlled. The next press then skips the switch and plays a pulse
while the firmware plays its own, which is felt as two clicks per press.

On a J313 across an s2idle cycle the driver says nothing between
'host-driven click feedback active' before the suspend and the first press
after it: it never re-took the mode because it believed it still held it.

Drop the cached mode in reset_resume, which the transport does call for the
trackpad, and drop the click state with it. The next press takes
host-controlled mode again if it needs it.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
The actuator keeps its mode across a warm reboot. Every user of it starts
out believing it is device-controlled, so one left in host-controlled mode
by a previous boot is silent: the firmware plays nothing because it no
longer owns the actuator, and the host that did is gone. The trackpad then
has no click feedback at all, with nothing in the log to say why. Only a
cold boot clears it.

Assert device-controlled mode at probe instead of inheriting whatever the
last boot left. The switch is not fatal if it fails, since that leaves the
actuator exactly where it already was.

Assisted-by: Claude:claude-opus-5
Assisted-by: DeepSeek:deepseek-v4.1
Signed-off-by: Chromatischer <dominik@hildania.de>
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