feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro
A DualSense on a phone had rumble only where the kernel exposed force feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android has no platform API for any of them, and Bluetooth offers no raw path (L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares Android triggers unsupported). Claiming the pad's HID interface over USB is the one unrooted route, so that is what the client now does. - HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed UsbRequest loop, newest-wins write queue, signalled-unplug discipline), parameterized by device match / interface filter / keep-alive. Sc2UsbLink keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh). - GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/ Trigger now route to a capture link that owns the pad BEFORE the InputDevice vibrator/lights paths — Trigger stops being log-and-drop. - DsDevice: the byte-exact inverse of the host's dualsense_proto / dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers, gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks and the lightbar-animation release; DS4 0x05 as composed full-state writes). Covered by DsDeviceTest (pure JVM). - DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire slot on the first parsed report, typed mirror (exit chord included), touch normalized onto the rich plane + per-report motion, feedback rendering with a rumble backstop (a USB pad holds its level, so a stalled poll thread self-terminates via a scheduled zero-write) and a teardown motor-stop over EP0. The claim releases the pad's InputDevice slot itself so the wire index hands over deterministically; uncaptured (toggle off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path. - Rich-input shims: nativeSendPadTouch / nativeSendPadMotion → RichInput::Touchpad / Motion — the plane the desktop and Apple clients already feed; Android pads gain gyro + touchpad on the virtual pad. - Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out like the SC2 toggle); Controllers screen card with capture status and a front-loaded USB grant so streams start without the permission dialog. The host needs nothing: the DS5/DS4 backends already consume the typed + rich planes and already emit every feedback event rendered here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
|
||||
// capture claims even those away), so it's enumerated on the capture side — USB device
|
||||
// list + bonded BLE — and re-checked on USB hot-plug.
|
||||
var sc2Generation by remember { mutableIntStateOf(0) }
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
|
||||
// row supplements the PadRow below with the capture status + the USB grant.
|
||||
var usbGeneration by remember { mutableIntStateOf(0) }
|
||||
DisposableEffect(Unit) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
|
||||
}
|
||||
val filter = android.content.IntentFilter().apply {
|
||||
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
val sc2Probe = remember { Sc2Capture(context) }
|
||||
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(sc2Generation) {
|
||||
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
|
||||
val sc2Ble = remember(usbGeneration) {
|
||||
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) sc2Probe.pairedBleAddress() else null
|
||||
}
|
||||
val sc2Present = sc2Usb != null || sc2Ble != null
|
||||
val dsUsb = remember(usbGeneration) {
|
||||
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
|
||||
.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
}
|
||||
|
||||
Group("Gamepads") {
|
||||
if (sc2Present) Sc2Row(sc2Usb, activity)
|
||||
dsUsb?.let { DsRow(it) }
|
||||
if (pads.isEmpty() && !sc2Present) {
|
||||
Text(
|
||||
"No controller detected. punktfunk can only forward devices Android " +
|
||||
@@ -319,6 +328,96 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sony USB pad card — capture status + the USB grant, front-loading the permission dialog so
|
||||
* the capture engages silently at stream start instead of interrupting it. Shown ALONGSIDE the
|
||||
* pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture itself only runs
|
||||
* inside a stream, so at menu time this card is pure status.
|
||||
*/
|
||||
@Composable
|
||||
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
|
||||
val context = LocalContext.current
|
||||
val settingOn = remember { SettingsStore(context).load().dsCapture }
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
|
||||
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
|
||||
val model = DsDevice.modelFor(usbDev.productId)
|
||||
val label = when (model) {
|
||||
DsDevice.Model.DUALSENSE -> "DualSense"
|
||||
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
|
||||
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
|
||||
null -> return
|
||||
}
|
||||
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
|
||||
// this receiver only updates the card).
|
||||
val action = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
|
||||
DisposableEffect(usbDev) {
|
||||
val receiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, i: android.content.Intent?) {
|
||||
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
|
||||
}
|
||||
}
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
android.content.IntentFilter(action),
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
onDispose { runCatching { context.unregisterReceiver(receiver) } }
|
||||
}
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Wired (USB)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
!settingOn -> Text(
|
||||
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
|
||||
"passthrough (USB)\" to capture it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
!permitted -> {
|
||||
Text(
|
||||
"Needs USB access — grant it now and streams capture the pad silently.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
android.app.PendingIntent.getBroadcast(
|
||||
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
|
||||
android.content.Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
android.app.PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
Text("Grant USB access")
|
||||
}
|
||||
}
|
||||
else -> Text(
|
||||
if (model == DsDevice.Model.DUALSHOCK4) {
|
||||
"Ready — captured at stream start: rumble, lightbar and gyro are " +
|
||||
"driven directly."
|
||||
} else {
|
||||
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
|
||||
"and gyro are driven directly."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One detected gamepad: identity, what it streams as, and a rumble test. */
|
||||
@Composable
|
||||
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
|
||||
|
||||
@@ -110,6 +110,18 @@ data class Settings(
|
||||
*/
|
||||
val sc2Capture: Boolean = true,
|
||||
|
||||
/**
|
||||
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
|
||||
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
|
||||
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
|
||||
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
|
||||
* platform API for any of them). ON by default — it engages only when such a pad is attached
|
||||
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
|
||||
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
|
||||
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
|
||||
*/
|
||||
val dsCapture: Boolean = true,
|
||||
|
||||
/**
|
||||
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
|
||||
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
|
||||
@@ -204,6 +216,7 @@ class SettingsStore(context: Context) {
|
||||
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
|
||||
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
|
||||
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
|
||||
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
|
||||
mouseMode = prefs.getString(K_MOUSE_MODE, null)
|
||||
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
|
||||
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
|
||||
@@ -234,6 +247,7 @@ class SettingsStore(context: Context) {
|
||||
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
|
||||
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
|
||||
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
|
||||
.putBoolean(K_DS_CAPTURE, s.dsCapture)
|
||||
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
|
||||
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
|
||||
.apply()
|
||||
@@ -274,6 +288,7 @@ class SettingsStore(context: Context) {
|
||||
const val K_AUTO_WAKE = "auto_wake_enabled"
|
||||
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
|
||||
const val K_SC2_CAPTURE = "sc2_capture"
|
||||
const val K_DS_CAPTURE = "ds_capture"
|
||||
const val K_MOUSE_MODE = "mouse_mode"
|
||||
|
||||
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
|
||||
|
||||
@@ -816,6 +816,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
|
||||
checked = s.sc2Capture,
|
||||
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
|
||||
)
|
||||
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
|
||||
// the CONTROLLER's own motors/LEDs, not this device's.
|
||||
ToggleRow(
|
||||
title = "DualSense / DualShock passthrough (USB)",
|
||||
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
|
||||
"plus adaptive triggers, lightbar and gyro",
|
||||
checked = s.dsCapture,
|
||||
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.unom.punktfunk.kit.DsCapture
|
||||
import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
@@ -367,13 +368,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
|
||||
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
|
||||
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
|
||||
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
|
||||
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
|
||||
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
|
||||
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
|
||||
// hands over deterministically.
|
||||
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
|
||||
var dsUsbReceiver: BroadcastReceiver? = null
|
||||
if (ds != null) {
|
||||
feedback.sink = ds
|
||||
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
val usbDev = ds.findUsbDevice()
|
||||
when {
|
||||
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
|
||||
usbDev != null -> {
|
||||
// One-time system dialog; capture engages on grant (Android remembers the
|
||||
// grant for as long as the device stays attached).
|
||||
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != action) return
|
||||
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
|
||||
}
|
||||
}
|
||||
dsUsbReceiver = receiver
|
||||
ContextCompat.registerReceiver(
|
||||
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
usbManager.requestPermission(
|
||||
usbDev,
|
||||
PendingIntent.getBroadcast(
|
||||
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
|
||||
Intent(action).setPackage(context.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
|
||||
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
|
||||
feedback.onHidRaw = null
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
|
||||
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
|
||||
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
|
||||
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
|
||||
activity?.gamepadRouter = null
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
|
||||
/**
|
||||
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB — stream mode only.
|
||||
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
|
||||
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
|
||||
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
|
||||
* phone, plus gyro + touchpad the standard path never captured.
|
||||
*
|
||||
* Unlike [Sc2Capture] there is no raw passthrough — the host's DualSense/DS4 backends consume
|
||||
* only typed events — and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
|
||||
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
|
||||
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
|
||||
* engage (toggle off, permission denied, Bluetooth).
|
||||
*
|
||||
* Input: parse ([DsDevice.parseState]) → typed mirror on an [GamepadRouter.ExternalPad] (buttons
|
||||
* diffed, axes on-change — the exit chord participates like any pad) + the rich plane (touch
|
||||
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
|
||||
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
|
||||
* and freed on unplug/[stop], so indices never leak.
|
||||
*
|
||||
* Feedback: implements [GamepadFeedback.PadFeedbackSink] — rumble / trigger / lightbar / player
|
||||
* LED events addressed to this pad's wire index become USB output reports on the physical pad
|
||||
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
|
||||
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
|
||||
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
|
||||
* itself if the poll thread stalls — the engine's explicit zeros remain the real stop mechanism.
|
||||
*/
|
||||
class DsCapture(
|
||||
context: Context,
|
||||
private val router: GamepadRouter,
|
||||
) : GamepadFeedback.PadFeedbackSink {
|
||||
private val usb = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = TAG,
|
||||
threadName = "pf-ds-usb",
|
||||
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
|
||||
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
|
||||
// class check already leaves them (and the pad's headset routing) to Android; the
|
||||
// single HID interface is the only claim.
|
||||
),
|
||||
::onReport,
|
||||
::onLinkClosed,
|
||||
)
|
||||
|
||||
@Volatile private var model: DsDevice.Model? = null
|
||||
@Volatile private var pad: GamepadRouter.ExternalPad? = null
|
||||
|
||||
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
|
||||
private val state = DsDevice.State()
|
||||
private var wireButtons = 0
|
||||
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
|
||||
private val lastTouchActive = BooleanArray(2)
|
||||
private val lastTouchX = IntArray(2) { -1 }
|
||||
private val lastTouchY = IntArray(2) { -1 }
|
||||
|
||||
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
|
||||
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
|
||||
// rumble, before any host Led lands) doesn't black the bar out.
|
||||
@Volatile private var ds4Low = 0
|
||||
@Volatile private var ds4High = 0
|
||||
@Volatile private var ds4Rgb = 0x000040
|
||||
|
||||
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
|
||||
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@Volatile private var backstop: Runnable? = null
|
||||
|
||||
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
|
||||
@Volatile
|
||||
var onActiveChanged: ((active: Boolean) -> Unit)? = null
|
||||
|
||||
val isActive: Boolean get() = model != null
|
||||
|
||||
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
|
||||
fun findUsbDevice(): UsbDevice? = usb.findDevice()
|
||||
|
||||
/**
|
||||
* Start capturing [dev] (permission already granted). Claims the HID interface — the kernel
|
||||
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
|
||||
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
|
||||
* than waiting for the system's removal callback — so the freed wire index is deterministic
|
||||
* for this capture's ExternalPad instead of racing the first report against the callback. A
|
||||
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
|
||||
* reopens a slot on its next input event, so over-matching self-heals.
|
||||
*/
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (model != null) return false
|
||||
val m = DsDevice.modelFor(dev.productId) ?: return false
|
||||
if (!usb.start(dev)) return false
|
||||
model = m
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
|
||||
}
|
||||
// Release the firmware's lightbar animation once so host lightbar writes take effect
|
||||
// (the same init hid-playstation/SDL send on open).
|
||||
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
|
||||
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
|
||||
onActiveChanged?.invoke(true)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
|
||||
fun stop() {
|
||||
val m = model
|
||||
if (m != null) {
|
||||
// The interfaces are about to release with the kernel driver still detached — a
|
||||
// mid-rumble teardown would leave the motors running with nobody to stop them.
|
||||
// EP0-direct (the reader thread is stopping; the queue would never drain).
|
||||
usb.writeControl(stopReport(m))
|
||||
}
|
||||
disarmBackstop()
|
||||
usb.stop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
// ---- link callbacks (link thread) ----
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val m = model ?: return
|
||||
if (!DsDevice.parseState(m, report, len, state)) return
|
||||
val p = pad ?: router.openExternal(m.pref)?.also {
|
||||
pad = it
|
||||
Log.i(TAG, "captured $m → wire pad ${it.index}")
|
||||
} ?: return // all 16 wire indices taken — drop until one frees
|
||||
mirrorTyped(p)
|
||||
mirrorRich(p, m)
|
||||
}
|
||||
|
||||
private fun onLinkClosed() {
|
||||
Log.i(TAG, "Sony USB link closed (unplug)")
|
||||
disarmBackstop()
|
||||
val wasActive = model != null
|
||||
model = null
|
||||
releaseSlot()
|
||||
if (wasActive) onActiveChanged?.invoke(false)
|
||||
}
|
||||
|
||||
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
|
||||
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
|
||||
var changed = state.buttons xor wireButtons
|
||||
while (changed != 0) {
|
||||
val bit = changed and -changed // lowest changed bit
|
||||
p.button(bit, state.buttons and bit != 0)
|
||||
changed = changed and bit.inv()
|
||||
}
|
||||
wireButtons = state.buttons
|
||||
axis(p, Gamepad.AXIS_LS_X, state.lsX)
|
||||
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
|
||||
axis(p, Gamepad.AXIS_RS_X, state.rsX)
|
||||
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
|
||||
axis(p, Gamepad.AXIS_LT, state.lt)
|
||||
axis(p, Gamepad.AXIS_RT, state.rt)
|
||||
}
|
||||
|
||||
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
|
||||
if (lastAxis[id] == v) return
|
||||
lastAxis[id] = v
|
||||
p.axis(id, v)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
|
||||
* on change per slot; motion forwarded every report (raw device units — the wire is a unit
|
||||
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
|
||||
*/
|
||||
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
|
||||
for (f in 0 until 2) {
|
||||
if (state.touchActive[f]) {
|
||||
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
|
||||
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
|
||||
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
|
||||
p.touch(f, true, x, y)
|
||||
lastTouchActive[f] = true
|
||||
lastTouchX[f] = x
|
||||
lastTouchY[f] = y
|
||||
}
|
||||
} else if (lastTouchActive[f]) {
|
||||
p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
lastTouchActive[f] = false
|
||||
}
|
||||
}
|
||||
p.motion(state.gyro, state.accel)
|
||||
}
|
||||
|
||||
private fun releaseSlot() {
|
||||
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
|
||||
val p = pad
|
||||
if (p != null) {
|
||||
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
|
||||
}
|
||||
p?.close()
|
||||
pad = null
|
||||
wireButtons = 0
|
||||
lastAxis.fill(Int.MIN_VALUE)
|
||||
lastTouchActive.fill(false)
|
||||
lastTouchX.fill(-1)
|
||||
lastTouchY.fill(-1)
|
||||
}
|
||||
|
||||
// ---- PadFeedbackSink (feedback poll threads) ----
|
||||
|
||||
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
|
||||
|
||||
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
|
||||
val m = model ?: return
|
||||
if (low == 0 && high == 0) {
|
||||
disarmBackstop()
|
||||
} else {
|
||||
armBackstop(backstopMs)
|
||||
}
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = low
|
||||
ds4High = high
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
|
||||
}
|
||||
}
|
||||
|
||||
override fun led(pad: Int, r: Int, g: Int, b: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Rgb = (r shl 16) or (g shl 8) or b
|
||||
writeDs4()
|
||||
} else {
|
||||
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
override fun playerLeds(pad: Int, bits: Int) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
|
||||
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
|
||||
}
|
||||
|
||||
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
|
||||
val m = model ?: return
|
||||
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
|
||||
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
|
||||
}
|
||||
|
||||
private fun writeDs4() = usb.writeRaw(
|
||||
0,
|
||||
DsDevice.ds4Report(
|
||||
ds4Low,
|
||||
ds4High,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
),
|
||||
)
|
||||
|
||||
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
|
||||
* composed motor state and carries the current lightbar rather than blacking it out. */
|
||||
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
|
||||
ds4Low = 0
|
||||
ds4High = 0
|
||||
DsDevice.ds4Report(
|
||||
0,
|
||||
0,
|
||||
(ds4Rgb shr 16) and 0xFF,
|
||||
(ds4Rgb shr 8) and 0xFF,
|
||||
ds4Rgb and 0xFF,
|
||||
)
|
||||
} else {
|
||||
DsDevice.ds5RumbleReport(m, 0, 0)
|
||||
}
|
||||
|
||||
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
|
||||
private fun armBackstop(ms: Long) {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
val r = Runnable {
|
||||
backstop = null
|
||||
model?.let { usb.writeRaw(0, stopReport(it)) }
|
||||
}
|
||||
backstop = r
|
||||
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun disarmBackstop() {
|
||||
backstop?.let { mainHandler.removeCallbacks(it) }
|
||||
backstop = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DsCapture"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
/**
|
||||
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
|
||||
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
|
||||
* as-is passthrough, nothing rides the wire raw here — the host's DualSense/DS4 backends consume
|
||||
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
|
||||
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
|
||||
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
|
||||
* USB output reports itself.
|
||||
*
|
||||
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
|
||||
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
|
||||
* `dualsense_proto.rs` / `dualshock4_proto.rs` — this file is the byte-exact inverse of those
|
||||
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
|
||||
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
|
||||
* reaches this code — an uncaptured pad stays on the ordinary InputDevice path.
|
||||
*/
|
||||
object DsDevice {
|
||||
const val VID_SONY = 0x054C
|
||||
const val PID_DUALSENSE = 0x0CE6
|
||||
const val PID_DUALSENSE_EDGE = 0x0DF2
|
||||
const val PID_DUALSHOCK4_V1 = 0x05C4
|
||||
const val PID_DUALSHOCK4_V2 = 0x09CC
|
||||
|
||||
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
|
||||
|
||||
/**
|
||||
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds — matching
|
||||
* the physical one), its output-report size (the descriptor-declared size the firmware
|
||||
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
|
||||
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
|
||||
* onto the wire's 0..65535 space.
|
||||
*/
|
||||
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
|
||||
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
|
||||
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
|
||||
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
|
||||
}
|
||||
|
||||
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
|
||||
fun modelFor(pid: Int): Model? = when (pid) {
|
||||
PID_DUALSENSE -> Model.DUALSENSE
|
||||
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
|
||||
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
|
||||
* (`Gamepad.BTN_*`) — the parse maps device bits straight to the wire, the exact inverse of
|
||||
* the host's `DsState::from_gamepad` (BTN_A ↔ cross, BTN_B ↔ circle, BTN_X ↔ square,
|
||||
* BTN_Y ↔ triangle; positional, not glyph-order). Gyro/accel stay in raw device units — the
|
||||
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
|
||||
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
|
||||
*/
|
||||
class State {
|
||||
var buttons = 0
|
||||
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
|
||||
var rsX = 0; var rsY = 0
|
||||
var lt = 0; var rt = 0 // 0..255
|
||||
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
|
||||
val accel = IntArray(3)
|
||||
val touchActive = BooleanArray(2)
|
||||
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
|
||||
val touchY = IntArray(2)
|
||||
}
|
||||
|
||||
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
|
||||
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
|
||||
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
|
||||
private const val DS5_INPUT_ID = 0x01
|
||||
// report[8] high nibble (`dualsense_proto::btn0`).
|
||||
private const val DS5_SQUARE = 0x10
|
||||
private const val DS5_CROSS = 0x20
|
||||
private const val DS5_CIRCLE = 0x40
|
||||
private const val DS5_TRIANGLE = 0x80
|
||||
// report[9] (`btn1`).
|
||||
private const val DS5_L1 = 0x01
|
||||
private const val DS5_R1 = 0x02
|
||||
private const val DS5_CREATE = 0x10
|
||||
private const val DS5_OPTIONS = 0x20
|
||||
private const val DS5_L3 = 0x40
|
||||
private const val DS5_R3 = 0x80
|
||||
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
|
||||
private const val DS5_PS = 0x01
|
||||
private const val DS5_TOUCHPAD = 0x02
|
||||
private const val DS5_MUTE = 0x04
|
||||
private const val EDGE_FN_LEFT = 0x10
|
||||
private const val EDGE_FN_RIGHT = 0x20
|
||||
private const val EDGE_BACK_LEFT = 0x40
|
||||
private const val EDGE_BACK_RIGHT = 0x80
|
||||
|
||||
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
|
||||
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
|
||||
// [35..43) two touch points (same 4-byte packing as the DS5).
|
||||
private const val DS4_L1 = 0x01
|
||||
private const val DS4_R1 = 0x02
|
||||
private const val DS4_SHARE = 0x10
|
||||
private const val DS4_OPTIONS = 0x20
|
||||
private const val DS4_L3 = 0x40
|
||||
private const val DS4_R3 = 0x80
|
||||
private const val DS4_PS = 0x01
|
||||
private const val DS4_TOUCHPAD = 0x02
|
||||
|
||||
/**
|
||||
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
|
||||
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 — those never hit
|
||||
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
|
||||
* is long enough to carry them (it always is on glass — 64-byte interrupt transfers).
|
||||
*/
|
||||
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
|
||||
if (model == Model.DUALSHOCK4) {
|
||||
parseDs4(report, len, out)
|
||||
} else {
|
||||
parseDs5(model, report, len, out)
|
||||
}
|
||||
|
||||
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
out.lt = u8(r, 5)
|
||||
out.rt = u8(r, 6)
|
||||
val b8 = u8(r, 8)
|
||||
val b9 = u8(r, 9)
|
||||
val b10 = u8(r, 10)
|
||||
var w = hatBits(b8 and 0x0F)
|
||||
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
// L2/R2 digital bits ride the analog axes instead (wire convention).
|
||||
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
|
||||
if (model == Model.DUALSENSE_EDGE) {
|
||||
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
|
||||
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
|
||||
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
|
||||
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
|
||||
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
|
||||
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
|
||||
}
|
||||
out.buttons = w
|
||||
if (len >= 28) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
|
||||
}
|
||||
if (len >= 41) {
|
||||
unpackTouch(r, 33, out, 0)
|
||||
unpackTouch(r, 37, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
|
||||
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
|
||||
out.lsX = stickX(u8(r, 1))
|
||||
out.lsY = stickY(u8(r, 2))
|
||||
out.rsX = stickX(u8(r, 3))
|
||||
out.rsY = stickY(u8(r, 4))
|
||||
val b5 = u8(r, 5)
|
||||
val b6 = u8(r, 6)
|
||||
val b7 = u8(r, 7)
|
||||
out.lt = u8(r, 8)
|
||||
out.rt = u8(r, 9)
|
||||
var w = hatBits(b5 and 0x0F)
|
||||
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
|
||||
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
|
||||
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
|
||||
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
|
||||
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
|
||||
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
|
||||
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
|
||||
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
|
||||
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
|
||||
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
|
||||
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
|
||||
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
|
||||
out.buttons = w
|
||||
if (len >= 25) {
|
||||
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
|
||||
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
|
||||
}
|
||||
if (len >= 43) {
|
||||
unpackTouch(r, 35, out, 0)
|
||||
unpackTouch(r, 39, out, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
|
||||
private fun hatBits(h: Int): Int = when (h) {
|
||||
0 -> Gamepad.BTN_DPAD_UP
|
||||
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
|
||||
2 -> Gamepad.BTN_DPAD_RIGHT
|
||||
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
|
||||
4 -> Gamepad.BTN_DPAD_DOWN
|
||||
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
|
||||
6 -> Gamepad.BTN_DPAD_LEFT
|
||||
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* One 4-byte touch point (shared DS5/DS4 packing — `dualsense_proto::pack_touch`): byte0
|
||||
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
|
||||
*/
|
||||
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
|
||||
val b0 = u8(r, o)
|
||||
out.touchActive[slot] = b0 and 0x80 == 0
|
||||
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
|
||||
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
|
||||
}
|
||||
|
||||
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
|
||||
|
||||
private fun i16(r: ByteArray, o: Int): Int =
|
||||
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
|
||||
|
||||
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
|
||||
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
|
||||
private fun stickX(raw: Int): Int = raw * 257 - 32768
|
||||
|
||||
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
|
||||
|
||||
// ---- Output reports ----
|
||||
//
|
||||
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
|
||||
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
|
||||
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
|
||||
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
|
||||
|
||||
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
|
||||
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
|
||||
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
|
||||
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
|
||||
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
|
||||
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
|
||||
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
|
||||
private const val DS5_FLAG0_R2_EFFECT = 0x04
|
||||
private const val DS5_FLAG0_L2_EFFECT = 0x08
|
||||
private const val DS5_FLAG1_LIGHTBAR = 0x04
|
||||
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
|
||||
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
|
||||
private const val DS5_FLAG2_VIBRATION2 = 0x04
|
||||
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
|
||||
|
||||
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
|
||||
const val TRIGGER_EFFECT_LEN = 11
|
||||
|
||||
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
|
||||
|
||||
/**
|
||||
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
|
||||
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect — the same
|
||||
* init both hid-playstation and SDL send on open. No-op fields otherwise.
|
||||
*/
|
||||
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
|
||||
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
|
||||
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
|
||||
* light/right — the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
|
||||
* the classic compat-vibration path AND `VIBRATION2` (firmware ≥ 2.24's full-range replot;
|
||||
* older firmware ignores the unknown flag2 bit) — the host parser accepts either.
|
||||
*/
|
||||
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
|
||||
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
|
||||
it[39] = DS5_FLAG2_VIBRATION2.toByte()
|
||||
it[3] = amp8(high).toByte()
|
||||
it[4] = amp8(low).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
|
||||
* trigger block from the wire (`HidOutput::Trigger` — the game's bytes verbatim), copied to
|
||||
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
|
||||
*/
|
||||
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
|
||||
val at = if (which == 1) 11 else 22
|
||||
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
|
||||
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
|
||||
System.arraycopy(effect, 0, it, at, n)
|
||||
}
|
||||
|
||||
/** DS5/Edge lightbar RGB. */
|
||||
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
|
||||
it[45] = r.toByte()
|
||||
it[46] = g.toByte()
|
||||
it[47] = b.toByte()
|
||||
}
|
||||
|
||||
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
|
||||
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
|
||||
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
|
||||
it[44] = (bits and 0x1F).toByte()
|
||||
}
|
||||
|
||||
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
|
||||
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
|
||||
// [6..9) RGB, [9]/[10] blink on/off.
|
||||
private const val DS4_FLAG0_MOTORS = 0x01
|
||||
private const val DS4_FLAG0_LED = 0x02
|
||||
|
||||
/**
|
||||
* One full-state DS4 write: motors + lightbar together, both flags set — the composed-state
|
||||
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
|
||||
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
|
||||
*/
|
||||
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
|
||||
ByteArray(Model.DUALSHOCK4.outputSize).also {
|
||||
it[0] = 0x05
|
||||
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
|
||||
it[4] = amp8(high).toByte()
|
||||
it[5] = amp8(low).toByte()
|
||||
it[6] = r.toByte()
|
||||
it[7] = g.toByte()
|
||||
it[8] = b.toByte()
|
||||
}
|
||||
|
||||
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
|
||||
// vibrator path's toAmplitude).
|
||||
private fun amp8(v16: Int): Int {
|
||||
val a = (v16 ushr 8) and 0xFF
|
||||
return if (v16 != 0 && a == 0) 1 else a
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,42 @@ class GamepadFeedback(
|
||||
private val router: GamepadRouter?,
|
||||
private val deviceVibrator: Vibrator? = null,
|
||||
) {
|
||||
/**
|
||||
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
|
||||
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
|
||||
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
|
||||
* resolves null and the platform paths no-op) — the link renders instead, by composing USB
|
||||
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
|
||||
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
|
||||
* Invoked on the feedback poll threads; implementations must be thread-safe.
|
||||
*/
|
||||
interface PadFeedbackSink {
|
||||
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
|
||||
* invoked while true. Racing a pad close is fine — a late render is a harmless no-op. */
|
||||
fun ownsPad(pad: Int): Boolean
|
||||
|
||||
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
|
||||
* [backstopMs] as the self-termination net — see [GamepadFeedback.renderRumble]). */
|
||||
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
|
||||
|
||||
/** Lightbar RGB. */
|
||||
fun led(pad: Int, r: Int, g: Int, b: Int)
|
||||
|
||||
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
|
||||
fun playerLeds(pad: Int, bits: Int)
|
||||
|
||||
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
|
||||
* block (mode byte + parameters) exactly as the game wrote it host-side. */
|
||||
fun trigger(pad: Int, which: Int, effect: ByteArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
|
||||
* [onHidRaw]; cleared before the poll threads stop.
|
||||
*/
|
||||
@Volatile
|
||||
var sink: PadFeedbackSink? = null
|
||||
|
||||
private companion object {
|
||||
const val TAG = "pf.feedback"
|
||||
const val TAG_LED: Byte = 0x01
|
||||
@@ -221,6 +257,12 @@ class GamepadFeedback(
|
||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||
// already decided the bind, and the user opted in.
|
||||
if (pad == 0) renderDeviceRumble(low, high, durationMs)
|
||||
// A captured pad's link renders on the physical controller itself (its slot has no
|
||||
// InputDevice, so the vibrator bind below would resolve null and drop the command).
|
||||
sink?.takeIf { it.ownsPad(pad) }?.let {
|
||||
it.rumble(pad, low, high, durationMs)
|
||||
return
|
||||
}
|
||||
val bind = rumbleBindFor(pad) ?: return
|
||||
val lo = toAmplitude(low)
|
||||
val hi = toAmplitude(high)
|
||||
@@ -313,23 +355,36 @@ class GamepadFeedback(
|
||||
val g = buf.get().toInt() and 0xFF
|
||||
val b = buf.get().toInt() and 0xFF
|
||||
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.led(pad, r, g, b)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
|
||||
}
|
||||
TAG_PLAYER_LEDS -> {
|
||||
val bits = buf.get().toInt() and 0x1F
|
||||
val player = playerIndexForBits(bits)
|
||||
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
|
||||
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null) s.playerLeds(pad, bits)
|
||||
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
|
||||
}
|
||||
TAG_TRIGGER -> {
|
||||
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
|
||||
val effLen = n - 3 // [pad][kind][which] header, then the effect block
|
||||
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 pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
|
||||
)
|
||||
val s = sink?.takeIf { it.ownsPad(pad) }
|
||||
if (s != null && effLen > 0) {
|
||||
// A captured DualSense: the raw trigger block replays onto the physical pad.
|
||||
val effect = ByteArray(effLen)
|
||||
buf.get(effect)
|
||||
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
|
||||
s.trigger(pad, which, effect)
|
||||
} else {
|
||||
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
|
||||
// No platform adaptive-trigger API — parse-validate the mode + log only.
|
||||
Log.i(
|
||||
TAG,
|
||||
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
TAG_HID_RAW -> {
|
||||
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
|
||||
|
||||
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
|
||||
}
|
||||
|
||||
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
|
||||
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
|
||||
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
|
||||
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
|
||||
}
|
||||
|
||||
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
|
||||
* units — the host passes them straight into the virtual pad's report). Per report. */
|
||||
fun motion(gyro: IntArray, accel: IntArray) {
|
||||
if (slot != null) {
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
|
||||
fun close() = closeSlot(syntheticId)
|
||||
}
|
||||
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
|
||||
return ExternalPad(syntheticId, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
|
||||
* detaches the kernel driver, so the system's own removal callback would close it moments
|
||||
* later anyway — doing it at claim time makes the freed wire index deterministic for the
|
||||
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
|
||||
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
|
||||
* slot on its next input event). Main thread, like the hot-plug callbacks.
|
||||
*/
|
||||
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
|
||||
|
||||
/**
|
||||
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
|
||||
* the feedback poll threads are joined (they read [deviceForPad]).
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* Generic USB transport for a client-captured HID controller — the device-agnostic half of what
|
||||
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
|
||||
* interface(s) — `force = true` detaches the kernel/OS driver, so a captured pad can't
|
||||
* double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest] read loop, and
|
||||
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
|
||||
* else EP0 `SET_REPORT`).
|
||||
*
|
||||
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
|
||||
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence —
|
||||
* the SC2's lizard-mode refresh; a DualSense needs none).
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (an SC2 on-glass round tripped exactly this — a 5 s silence heuristic firing on an idle pad).
|
||||
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
*/
|
||||
class HidUsbLink(
|
||||
private val context: Context,
|
||||
private val config: Config,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
|
||||
* HID/vendor-class interfaces get claimed (the class check itself is built in) — e.g. the SC2
|
||||
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
|
||||
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
|
||||
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
|
||||
*/
|
||||
class Config(
|
||||
val tag: String,
|
||||
val threadName: String,
|
||||
val deviceMatch: (UsbDevice) -> Boolean,
|
||||
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
|
||||
val keepAliveFeatures: List<ByteArray> = emptyList(),
|
||||
val keepAliveMs: Long = 0,
|
||||
)
|
||||
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where output/feature writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports, submitted by the reader thread — only one thread may drive a
|
||||
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
|
||||
* request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached matching device, or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
config.tag,
|
||||
"USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(config.tag, "USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
if (config.keepAliveFeatures.isNotEmpty()) {
|
||||
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
}
|
||||
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
|
||||
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional — the fallback is
|
||||
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
|
||||
* from Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (!config.ifaceFilter(dev, iface)) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(config.tag, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(config.tag, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
|
||||
now - lastKeepAlive >= config.keepAliveMs
|
||||
) {
|
||||
// Refresh the firmware settings on the streaming interface (else every live
|
||||
// one, before a streaming interface is known) — replaying also repairs state
|
||||
// some other consumer changed after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) sendKeepAlive(conn, target.iface.id)
|
||||
else live.forEach { sendKeepAlive(conn, it.iface.id) }
|
||||
lastKeepAlive = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
config.tag,
|
||||
"first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one raw report to the device: kind 0 = output report (the active interface's
|
||||
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
|
||||
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the sender re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
|
||||
* queue — for a teardown write that must land while the reader thread is stopping and the
|
||||
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
|
||||
* thread: EP0 control transfers are independent of the reader's `requestWait`.
|
||||
*/
|
||||
fun writeControl(data: ByteArray) {
|
||||
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
|
||||
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,30 @@ object NativeBridge {
|
||||
*/
|
||||
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
|
||||
|
||||
/**
|
||||
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
|
||||
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
|
||||
* are normalized 0..65535 in SCREEN convention (+y down — the wire's fixed meaning); active
|
||||
* false lifts the finger. Send on change only — the host holds per-slot state.
|
||||
*/
|
||||
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
|
||||
|
||||
/**
|
||||
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
|
||||
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units — the host passes
|
||||
* them straight into the virtual DualSense report. Called at the pad's report rate.
|
||||
*/
|
||||
external fun nativeSendPadMotion(
|
||||
handle: Long,
|
||||
pad: Int,
|
||||
gyroPitch: Int,
|
||||
gyroYaw: Int,
|
||||
gyroRoll: Int,
|
||||
accelX: Int,
|
||||
accelY: Int,
|
||||
accelZ: Int,
|
||||
)
|
||||
|
||||
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.hardware.usb.UsbRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck
|
||||
* dongle (`1304`/`1305`). Claims the controller interface(s) — detaching the OS input stack, so
|
||||
* the pad can't double-drive the ordinary InputDevice path — runs a multiplexed [UsbRequest]
|
||||
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
|
||||
* writes (Steam's rumble output reports / settings feature reports) back to the device.
|
||||
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
|
||||
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
|
||||
* SC2-specific:
|
||||
*
|
||||
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
|
||||
* HID interface each, and there is no way to know which slot a controller bonded to — claiming
|
||||
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
|
||||
* on-glass symptom: the pad surfaced as a generic InputDevice → Xbox360). Whichever interface
|
||||
* streams state becomes the write target for rumble/settings.
|
||||
*
|
||||
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
|
||||
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
|
||||
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
|
||||
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
|
||||
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
|
||||
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
|
||||
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence — the generic link's keep-alive.
|
||||
*/
|
||||
class Sc2UsbLink(
|
||||
private val context: Context,
|
||||
private val onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
private val onClosed: () -> Unit,
|
||||
context: Context,
|
||||
onReport: (report: ByteArray, len: Int) -> Unit,
|
||||
onClosed: () -> Unit,
|
||||
) {
|
||||
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** One claimed interface: its endpoints + the read state the reader thread owns. */
|
||||
private class Claim(
|
||||
val iface: UsbInterface,
|
||||
val epIn: UsbEndpoint,
|
||||
val epOut: UsbEndpoint?,
|
||||
) {
|
||||
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
|
||||
var inReq: UsbRequest? = null
|
||||
var outReq: UsbRequest? = null
|
||||
var outBusy = false
|
||||
var reports = 0L
|
||||
}
|
||||
|
||||
private var connection: UsbDeviceConnection? = null
|
||||
private var device: UsbDevice? = null
|
||||
private var claims: List<Claim> = emptyList()
|
||||
|
||||
/** The claim whose IN endpoint last produced data — where rumble/settings writes go.
|
||||
* Written by the reader thread, read by the feedback thread (feature control transfers). */
|
||||
@Volatile private var activeClaim: Claim? = null
|
||||
|
||||
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — only
|
||||
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
|
||||
* returns ANY completed request; a second waiter would steal the reader's completions). */
|
||||
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private var reader: Thread? = null
|
||||
private var detachReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Volatile private var running = false
|
||||
private val link = HidUsbLink(
|
||||
context,
|
||||
HidUsbLink.Config(
|
||||
tag = "Sc2UsbLink",
|
||||
threadName = "pf-sc2-usb",
|
||||
deviceMatch = {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
},
|
||||
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
|
||||
ifaceFilter = { dev, iface ->
|
||||
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
|
||||
},
|
||||
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
|
||||
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
|
||||
),
|
||||
onReport,
|
||||
onClosed,
|
||||
)
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
|
||||
}
|
||||
fun findDevice(): UsbDevice? = link.findDevice()
|
||||
|
||||
/**
|
||||
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
|
||||
* obtained USB permission. Returns false when nothing could be claimed.
|
||||
*/
|
||||
fun start(dev: UsbDevice): Boolean {
|
||||
if (!usb.hasPermission(dev)) {
|
||||
Log.e(TAG, "no USB permission for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val conn = usb.openDevice(dev) ?: run {
|
||||
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
|
||||
return false
|
||||
}
|
||||
val claimed = claimControllerInterfaces(dev, conn)
|
||||
if (claimed.isEmpty()) {
|
||||
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
|
||||
conn.close()
|
||||
return false
|
||||
}
|
||||
connection = conn
|
||||
device = dev
|
||||
claims = claimed
|
||||
running = true
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
|
||||
dev.productId,
|
||||
claimed.joinToString {
|
||||
"%d(in=0x%02x out=%s)".format(
|
||||
it.iface.id, it.epIn.address,
|
||||
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
|
||||
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (gone?.deviceName == dev.deviceName) {
|
||||
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
detachReceiver = receiver
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
claimed.forEach { configureInputMode(conn, it.iface.id) }
|
||||
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
|
||||
* of a Puck's controller slots (interfaces 2..5 — the controller may be bonded to any of
|
||||
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
|
||||
* Android's own input stack while captured.
|
||||
*/
|
||||
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
|
||||
val dongle = dev.productId != Sc2Device.PID_WIRED
|
||||
val out = mutableListOf<Claim>()
|
||||
for (i in 0 until dev.interfaceCount) {
|
||||
val iface = dev.getInterface(i)
|
||||
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
|
||||
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
|
||||
iface.interfaceClass == 0xFF
|
||||
if (!hidOrVendor) continue
|
||||
var inEp: UsbEndpoint? = null
|
||||
var outEp: UsbEndpoint? = null
|
||||
for (e in 0 until iface.endpointCount) {
|
||||
val ep = iface.getEndpoint(e)
|
||||
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
|
||||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
|
||||
if (!usable) continue
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
|
||||
}
|
||||
if (inEp == null) continue
|
||||
if (conn.claimInterface(iface, true)) {
|
||||
out.add(Claim(iface, inEp, outEp))
|
||||
} else {
|
||||
Log.w(TAG, "could not claim iface ${iface.id}")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
|
||||
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
|
||||
*/
|
||||
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
|
||||
val live = claims.filter { c ->
|
||||
val req = UsbRequest()
|
||||
if (!req.initialize(conn, c.epIn)) {
|
||||
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
|
||||
return@filter false
|
||||
}
|
||||
req.clientData = c
|
||||
c.inReq = req
|
||||
c.epOut?.let { ep ->
|
||||
val o = UsbRequest()
|
||||
if (o.initialize(conn, ep)) {
|
||||
o.clientData = c
|
||||
c.outReq = o
|
||||
} else {
|
||||
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
|
||||
}
|
||||
}
|
||||
c.inBuf.clear()
|
||||
req.queue(c.inBuf)
|
||||
}
|
||||
if (live.isEmpty()) {
|
||||
Log.e(TAG, "no IN request could be queued")
|
||||
finishReader(claims)
|
||||
return
|
||||
}
|
||||
val scratch = ByteArray(64)
|
||||
var lastLizard = android.os.SystemClock.elapsedRealtime()
|
||||
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
|
||||
try {
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
// Refresh both required firmware modes. The raw-joystick setting is normally
|
||||
// persistent, but replaying it also repairs a host/driver that enabled ADC
|
||||
// coordinates after capture started.
|
||||
val target = activeClaim
|
||||
if (target != null) configureInputMode(conn, target.iface.id)
|
||||
else live.forEach { configureInputMode(conn, it.iface.id) }
|
||||
lastLizard = now
|
||||
}
|
||||
// Submit the next pending OUT report on the active (else first) interface.
|
||||
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
|
||||
if (outTarget != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS)
|
||||
} catch (_: TimeoutException) {
|
||||
// A quiet controller is NOT an unplug — keep listening indefinitely; the
|
||||
// detach broadcast is the real signal.
|
||||
errorsSince = 0L
|
||||
continue
|
||||
}
|
||||
if (done == null) {
|
||||
// Hard error. On a real unplug these storm continuously (the detach
|
||||
// broadcast usually beats us to it); tolerate transient ones.
|
||||
if (errorsSince == 0L) errorsSince = now
|
||||
if (now - errorsSince >= ERROR_UNPLUG_MS) {
|
||||
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
errorsSince = 0L
|
||||
val claim = done.clientData as? Claim ?: continue
|
||||
if (done === claim.inReq) {
|
||||
val n = claim.inBuf.position()
|
||||
if (n > 0) {
|
||||
claim.inBuf.flip()
|
||||
claim.inBuf.get(scratch, 0, n)
|
||||
if (claim.reports++ == 0L) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"SC2 first report on iface %d: id=0x%02x len=%d".format(
|
||||
claim.iface.id, scratch[0].toInt() and 0xFF, n,
|
||||
),
|
||||
)
|
||||
}
|
||||
activeClaim = claim
|
||||
onReport(scratch, n)
|
||||
}
|
||||
claim.inBuf.clear()
|
||||
if (!claim.inReq!!.queue(claim.inBuf)) {
|
||||
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
|
||||
break
|
||||
}
|
||||
} else if (done === claim.outReq) {
|
||||
claim.outBusy = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishReader(claims)
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishReader(claims: List<Claim>) {
|
||||
for (c in claims) {
|
||||
runCatching { c.inReq?.cancel(); c.inReq?.close() }
|
||||
runCatching { c.outReq?.cancel(); c.outReq?.close() }
|
||||
c.inReq = null
|
||||
c.outReq = null
|
||||
}
|
||||
}
|
||||
fun start(dev: UsbDevice): Boolean = link.start(dev)
|
||||
|
||||
/**
|
||||
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
|
||||
* rumble & friends — the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
|
||||
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
|
||||
* report, id byte first, exactly as hidapi framed it host-side.
|
||||
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
|
||||
* exactly as hidapi framed it host-side.
|
||||
*/
|
||||
fun writeRaw(kind: Int, data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
|
||||
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
|
||||
// newest-wins: these are level-styled commands the host re-sends anyway.
|
||||
while (outQueue.size >= 32) outQueue.poll()
|
||||
outQueue.offer(data)
|
||||
} else {
|
||||
setReport(REPORT_TYPE_OUTPUT, data)
|
||||
}
|
||||
}
|
||||
1 -> setReport(REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
}
|
||||
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
|
||||
|
||||
private fun setReport(type: Int, data: ByteArray) {
|
||||
val conn = connection ?: return
|
||||
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
|
||||
sendReport(conn, ifId, type, data)
|
||||
}
|
||||
|
||||
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
|
||||
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
|
||||
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
|
||||
}
|
||||
|
||||
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
|
||||
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
|
||||
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
|
||||
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
|
||||
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
|
||||
*/
|
||||
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
|
||||
val id = data[0].toInt() and 0xFF
|
||||
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
|
||||
conn.controlTransfer(
|
||||
0x21, // host→device, class, interface
|
||||
0x09, // SET_REPORT
|
||||
(type shl 8) or id,
|
||||
ifaceId,
|
||||
payload,
|
||||
payload.size,
|
||||
WRITE_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
|
||||
fun stop() {
|
||||
running = false
|
||||
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
|
||||
detachReceiver = null
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
activeClaim = null
|
||||
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
|
||||
claims = emptyList()
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
device = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100L
|
||||
const val WRITE_TIMEOUT_MS = 250
|
||||
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
|
||||
const val ERROR_UNPLUG_MS = 2000L
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
|
||||
fun stop() = link.stop()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure JVM tests of the Sony USB report codec ([DsDevice]) — the byte-exact inverse of the
|
||||
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
|
||||
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class DsDeviceTest {
|
||||
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
// Sticks centred, hat neutral (8).
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[8] = 0x08
|
||||
// Touch points inactive (bit7 set).
|
||||
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
|
||||
ByteArray(64).also {
|
||||
it[0] = 0x01
|
||||
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
|
||||
it[5] = 0x08
|
||||
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
|
||||
mutate(it)
|
||||
}
|
||||
|
||||
// ---- input parse ----
|
||||
|
||||
@Test
|
||||
fun ds5ButtonsMapPositionally() {
|
||||
val s = DsDevice.State()
|
||||
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
|
||||
val r = ds5Report {
|
||||
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
|
||||
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
|
||||
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
|
||||
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
|
||||
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
|
||||
assertEquals(expected, s.buttons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5SticksInvertYAndCoverTheFullRange() {
|
||||
val s = DsDevice.State()
|
||||
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
|
||||
val r = ds5Report {
|
||||
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
|
||||
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
|
||||
it[5] = 0x40; it[6] = 0xFF.toByte()
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(-32768, s.lsX)
|
||||
assertEquals(32767, s.lsY) // device up → wire +32767
|
||||
assertEquals(32767, s.rsX)
|
||||
assertEquals(-32768, s.rsY) // device down → wire −32768
|
||||
assertEquals(0x40, s.lt)
|
||||
assertEquals(0xFF, s.rt)
|
||||
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
|
||||
val c = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
|
||||
assertEquals(128, c.lsX)
|
||||
assertEquals(-129, c.lsY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5MotionAndTouchUnpack() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds5Report {
|
||||
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
|
||||
it[16] = 0x02; it[17] = 0x01
|
||||
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
|
||||
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
|
||||
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
|
||||
it[33] = 0x05
|
||||
it[34] = 0x7F
|
||||
it[35] = (0x07 or (0x07 shl 4)).toByte()
|
||||
it[36] = 0x43
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
|
||||
assertEquals(0x0102, s.gyro[0])
|
||||
assertEquals(-2, s.accel[2])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(1919, s.touchX[0])
|
||||
assertEquals(1079, s.touchY[0])
|
||||
assertFalse(s.touchActive[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun edgePaddlesParseOnlyOnTheEdge() {
|
||||
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
|
||||
val edge = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
|
||||
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
|
||||
assertEquals(
|
||||
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
|
||||
edge.buttons,
|
||||
)
|
||||
val plain = DsDevice.State()
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
|
||||
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4LayoutDiffersWhereItShould() {
|
||||
val s = DsDevice.State()
|
||||
val r = ds4Report {
|
||||
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
|
||||
it[6] = (0x10 or 0x20).toByte() // share | options
|
||||
it[7] = 0x03 // PS | touchpad click
|
||||
it[8] = 0x11 // L2 analog
|
||||
it[9] = 0x99.toByte() // R2 analog
|
||||
// gyro yaw at 15.. (second i16 of 13..19).
|
||||
it[15] = 0x34; it[16] = 0x12
|
||||
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
|
||||
it[35] = 0x03
|
||||
it[36] = 0x64
|
||||
it[37] = 0xD0.toByte()
|
||||
it[38] = 0x3A
|
||||
}
|
||||
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
|
||||
assertEquals(
|
||||
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
|
||||
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
|
||||
s.buttons,
|
||||
)
|
||||
assertEquals(0x11, s.lt)
|
||||
assertEquals(0x99, s.rt)
|
||||
assertEquals(0x1234, s.gyro[1])
|
||||
assertTrue(s.touchActive[0])
|
||||
assertEquals(100, s.touchX[0])
|
||||
assertEquals(941, s.touchY[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsForeignAndShortReports() {
|
||||
val s = DsDevice.State()
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
|
||||
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
|
||||
}
|
||||
|
||||
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
|
||||
|
||||
@Test
|
||||
fun ds5RumbleReportFlagsAndMotors() {
|
||||
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
|
||||
assertEquals(48, r.size)
|
||||
assertEquals(0x02, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
|
||||
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
|
||||
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
|
||||
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
|
||||
// A nonzero amplitude never collapses to motor 0.
|
||||
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
|
||||
// The Edge's output report is the 64-byte variant.
|
||||
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5TriggerReportPlacesTheBlockPerSide() {
|
||||
val effect = ByteArray(11) { (it + 1).toByte() }
|
||||
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
|
||||
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
|
||||
assertEquals(1, r2[11].toInt()) // block at [11..22)
|
||||
assertEquals(11, r2[21].toInt())
|
||||
assertEquals(0, r2[22].toInt())
|
||||
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
|
||||
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
|
||||
assertEquals(1, l2[22].toInt()) // block at [22..33)
|
||||
assertEquals(11, l2[32].toInt())
|
||||
// Oversized wire effects clamp to the 11-byte hardware block.
|
||||
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
|
||||
assertEquals(0, big[22].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds5LightbarPlayerLedsAndInit() {
|
||||
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
|
||||
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
|
||||
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
|
||||
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
|
||||
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
|
||||
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
|
||||
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
|
||||
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
|
||||
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ds4ReportIsAFullStateWrite() {
|
||||
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
|
||||
assertEquals(32, r.size)
|
||||
assertEquals(0x05, r[0].toInt())
|
||||
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
|
||||
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
|
||||
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
|
||||
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
|
||||
assertEquals(0, r[9].toInt()) // blink untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelResolution() {
|
||||
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
|
||||
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
|
||||
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
|
||||
assertEquals(null, DsDevice.modelFor(0x1234))
|
||||
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
|
||||
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
|
||||
}
|
||||
}
|
||||
@@ -406,3 +406,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
|
||||
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
|
||||
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
|
||||
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
|
||||
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
|
||||
/// the capture diffs, the host holds per-slot state.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
finger: jint,
|
||||
active: jboolean,
|
||||
x: jint,
|
||||
y: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Touchpad {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
finger: (finger as u32 & 0x1) as u8,
|
||||
active: active != 0,
|
||||
x: (x as i64).clamp(0, 65535) as u16,
|
||||
y: (y as i64).clamp(0, 65535) as u16,
|
||||
});
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
|
||||
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
|
||||
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
|
||||
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
|
||||
/// controller's report rate.
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
|
||||
_env: JNIEnv,
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
pad: jint,
|
||||
gyro_pitch: jint,
|
||||
gyro_yaw: jint,
|
||||
gyro_roll: jint,
|
||||
accel_x: jint,
|
||||
accel_y: jint,
|
||||
accel_z: jint,
|
||||
) {
|
||||
if handle == 0 {
|
||||
return;
|
||||
}
|
||||
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let _ = h.client.send_rich_input(RichInput::Motion {
|
||||
pad: (pad as u32 & 0xF) as u8,
|
||||
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
|
||||
accel: [c(accel_x), c(accel_y), c(accel_z)],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user