feat(android): DualSense host->client feedback — rumble + lightbar/LEDs/triggers
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m39s
ci / rust (push) Failing after 1m44s
ci / web (push) Successful in 27s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Successful in 3m10s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m18s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m39s
ci / rust (push) Failing after 1m44s
ci / web (push) Successful in 27s
ci / bench (push) Successful in 1m44s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Successful in 3m10s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 1m18s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m31s
M4 Android stage 1 (DualSense feedback, host->client). Two Kotlin poll threads drain the connector's rumble (0xCA) + HID-output (0xCD) planes via blocking native pulls and render in Kotlin (Option B — no JNI upcalls, Android APIs stay in Kotlin). - crates/punktfunk-android: feedback.rs — nativeNextRumble (returns (low<<16)|high, or -1) + nativeNextHidout (writes [kind][fields] into a caller's direct ByteBuffer). Ungated; no new Cargo deps (next_rumble/next_hidout are on the quic feature already). - clients/android: GamepadFeedback.kt — rumble -> VibratorManager (two-motor amplitude), HID Led -> lightbar + PlayerLeds -> player LED via LightsManager (API 33+), adaptive triggers parsed + logged (no public Android API); resolves the connected pad, emulator -> logged no-op. Started/stopped in the StreamScreen lifecycle (stop + join before nativeClose). Verified live (emulator -> synthetic host, PUNKTFUNK_TEST_FEEDBACK=1): client received + decoded the full burst -- rumble low=16384 high=32768, Led r=10 g=20 b=30, PlayerLeds bits=4 player=1, Trigger which=1 mode=0x21 -- matching the host hook exactly. Rendering is a logged no-op on the emulator (no controller); real haptics/lightbar/player-LED need a physical pad. Deferred (need a physical DualSense + device enumeration): client->host rich input (touchpad/motion send_rich_input) and DualSense controller-type negotiation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.Keymap
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import kotlin.math.abs
|
||||
@@ -205,7 +206,10 @@ private fun StreamScreen(handle: Long, onDisconnect: () -> Unit) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
activity?.streamHandle = handle // route hardware keys to this session
|
||||
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
|
||||
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||
onDispose {
|
||||
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
|
||||
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
|
||||
activity?.axisMapper = null
|
||||
activity?.streamHandle = 0L
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.graphics.Color
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
import android.hardware.lights.LightsManager
|
||||
import android.hardware.lights.LightsRequest
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
|
||||
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
|
||||
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
|
||||
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
|
||||
*
|
||||
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
|
||||
* flag; the ~100 ms native pull timeout lets the threads exit, then they're joined (bounded) — and
|
||||
* this MUST run before `nativeClose` frees the session handle.
|
||||
*
|
||||
* The active pad is resolved from the connected input devices (first gamepad/joystick). With none
|
||||
* connected (emulator) rumble/lights become logged no-ops — exactly the verification path; the
|
||||
* `Log.i` receipt lines fire regardless of rendering hardware.
|
||||
*/
|
||||
class GamepadFeedback(private val handle: Long) {
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
const val TAG_PLAYER_LEDS: Byte = 0x02
|
||||
const val TAG_TRIGGER: Byte = 0x03
|
||||
}
|
||||
|
||||
@Volatile private var running = false
|
||||
private var rumbleThread: Thread? = null
|
||||
private var hidoutThread: Thread? = null
|
||||
|
||||
private var vm: VibratorManager? = null
|
||||
private var vibratorIds: IntArray = IntArray(0)
|
||||
private var amplitudeControlled = false
|
||||
|
||||
private var lightsSession: LightsManager.LightsSession? = null
|
||||
private var rgbLight: Light? = null
|
||||
private var playerLight: Light? = null
|
||||
|
||||
fun start() {
|
||||
val dev = resolvePad()
|
||||
bindRumble(dev)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
bindLights(dev)
|
||||
} else {
|
||||
Log.i(TAG, "lights need API 33 (have ${Build.VERSION.SDK_INT}) — lightbar/playerLed no-op")
|
||||
}
|
||||
|
||||
running = true
|
||||
rumbleThread = Thread({
|
||||
while (running) {
|
||||
val ev = NativeBridge.nativeNextRumble(handle)
|
||||
if (ev < 0L) continue // timeout / closed
|
||||
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
|
||||
}
|
||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||
|
||||
hidoutThread = Thread({
|
||||
val buf = ByteBuffer.allocateDirect(64)
|
||||
while (running) {
|
||||
val n = NativeBridge.nativeNextHidout(handle, buf)
|
||||
if (n < 0) continue // timeout / closed
|
||||
dispatchHidout(buf, n)
|
||||
}
|
||||
}, "pf-hidout").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
/** Idempotent. Stops + joins the poll threads (must complete before the session handle is freed). */
|
||||
fun stop() {
|
||||
running = false
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { rumbleThread?.join(500) }
|
||||
runCatching { hidoutThread?.join(500) }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
lightsSession = null
|
||||
rgbLight = null
|
||||
playerLight = null
|
||||
vm = null
|
||||
vibratorIds = IntArray(0)
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
private fun bindRumble(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
val m = dev.vibratorManager
|
||||
val ids = m.vibratorIds
|
||||
if (ids.isEmpty()) {
|
||||
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
|
||||
return
|
||||
}
|
||||
vm = m
|
||||
vibratorIds = ids
|
||||
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
|
||||
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
|
||||
}
|
||||
|
||||
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
|
||||
private fun renderRumble(low: Int, high: Int) {
|
||||
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
|
||||
val m = vm ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
if (lo == 0 && hi == 0) {
|
||||
m.cancel() // (0,0) = stop
|
||||
return
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (amplitudeControlled && vibratorIds.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
|
||||
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
|
||||
} else {
|
||||
// Single motor or no amplitude control: blend both into one effect.
|
||||
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
|
||||
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
|
||||
}
|
||||
runCatching { m.vibrate(combo.combine()) }
|
||||
}
|
||||
|
||||
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
|
||||
private fun toAmplitude(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
|
||||
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero.
|
||||
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
|
||||
|
||||
// ---- HID output ----
|
||||
|
||||
private fun dispatchHidout(buf: ByteBuffer, n: Int) {
|
||||
buf.rewind()
|
||||
when (buf.get()) { // kind tag
|
||||
TAG_LED -> {
|
||||
val r = buf.get().toInt() and 0xFF
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 2
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
}
|
||||
else -> Log.d(TAG, "hidout: unknown kind, dropped")
|
||||
}
|
||||
}
|
||||
|
||||
/** hid-playstation 5-LED pattern → player index 1..4 (0 = off); falls back to a bit count. */
|
||||
private fun playerIndexForBits(bits: Int): Int = when (bits and 0x1F) {
|
||||
0b00000 -> 0
|
||||
0b00100 -> 1
|
||||
0b01010 -> 2
|
||||
0b10101 -> 3
|
||||
0b11011 -> 4
|
||||
else -> Integer.bitCount(bits and 0x1F).coerceIn(1, 4)
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice?) {
|
||||
if (dev == null) {
|
||||
Log.i(TAG, "lights: no controller connected — lightbar/playerLed no-op (emulator path)")
|
||||
return
|
||||
}
|
||||
val lm = dev.lightsManager
|
||||
for (l in lm.lights) {
|
||||
if (rgbLight == null && l.hasRgbControl()) rgbLight = l
|
||||
if (playerLight == null && l.type == Light.LIGHT_TYPE_PLAYER_ID) playerLight = l
|
||||
}
|
||||
if (rgbLight == null && playerLight == null) {
|
||||
Log.i(TAG, "lights: controller '${dev.name}' exposes no controllable lights — no-op")
|
||||
return
|
||||
}
|
||||
lightsSession = lm.openSession()
|
||||
Log.i(TAG, "lights: bound rgb=${rgbLight != null} playerLed=${playerLight != null}")
|
||||
}
|
||||
|
||||
private fun setLightbar(argb: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = rgbLight ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setColor(argb).build()).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerId(player: Int) {
|
||||
val s = lightsSession ?: return
|
||||
val l = playerLight ?: return
|
||||
runCatching {
|
||||
s.requestLights(LightsRequest.Builder().addLight(l, LightState.Builder().setPlayerId(player).build()).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,19 @@ object NativeBridge {
|
||||
|
||||
/** One gamepad axis update. axisId: [Gamepad].AXIS_* (0..5). value: stick i16 (+y=up) / trigger 0..255. */
|
||||
external fun nativeSendGamepadAxis(handle: Long, axisId: Int, value: Int)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next rumble update. Returns `(low shl 16) or high` (each
|
||||
* 0..0xFFFF; 0 = stop), or -1 on timeout / session closed. Call from a dedicated poll thread.
|
||||
*/
|
||||
external fun nativeNextRumble(handle: Long): Long
|
||||
|
||||
/**
|
||||
* Block up to ~100 ms for the next DualSense HID-output event, written into [buf] (a direct
|
||||
* ByteBuffer, capacity >= 64) as `[kind][fields…]`: Led=01 r g b, PlayerLeds=02 bits,
|
||||
* Trigger=03 which effect…. Returns the byte count, or -1 on timeout / session closed.
|
||||
*/
|
||||
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user