From 1984ddb942e65f29657c99bae53e21e2c36cd178 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 17:40:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20a=20USB=20Sony=20pad=20is=20ca?= =?UTF-8?q?ptured=20=E2=80=94=20rumble,=20adaptive=20triggers,=20lightbar,?= =?UTF-8?q?=20gyro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/unom/punktfunk/ControllersScreen.kt | 113 ++++- .../main/kotlin/io/unom/punktfunk/Settings.kt | 15 + .../io/unom/punktfunk/SettingsScreen.kt | 9 + .../kotlin/io/unom/punktfunk/StreamScreen.kt | 47 +++ .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 301 +++++++++++++ .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 340 +++++++++++++++ .../io/unom/punktfunk/kit/GamepadFeedback.kt | 71 +++- .../io/unom/punktfunk/kit/GamepadRouter.kt | 28 ++ .../io/unom/punktfunk/kit/HidUsbLink.kt | 399 ++++++++++++++++++ .../io/unom/punktfunk/kit/NativeBridge.kt | 24 ++ .../io/unom/punktfunk/kit/Sc2UsbLink.kt | 383 ++--------------- .../io/unom/punktfunk/kit/DsDeviceTest.kt | 224 ++++++++++ clients/android/native/src/session/input.rs | 63 +++ 13 files changed, 1653 insertions(+), 364 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 9211fb34..342be705 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -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) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index fb0d163d..13950ae7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -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. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 8ff7167f..76c1495f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -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)) }, + ) } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index fc900ee8..f4f861d3 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -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 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt new file mode 100644 index 00000000..47c2eaa3 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -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" + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt new file mode 100644 index 00000000..f0f4f5ea --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -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 + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index d7a4af37..63d22d57 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -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 — diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index cf00d309..46c365ce 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -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]). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt new file mode 100644 index 00000000..5a6b96e5 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt @@ -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 = 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 = 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() + + 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 { + val out = mutableListOf() + 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) { + 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) { + 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 + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index a61dbbe1..8cd56ccd 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -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) ---- /** diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2UsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2UsbLink.kt index 040d5d29..186c3520 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2UsbLink.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2UsbLink.kt @@ -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 = 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() - - 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 { - val dongle = dev.productId != Sc2Device.PID_WIRED - val out = mutableListOf() - 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) { - 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) { - 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() } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt new file mode 100644 index 00000000..ca1fb3ca --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DsDeviceTest.kt @@ -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) + } +} diff --git a/clients/android/native/src/session/input.rs b/clients/android/native/src/session/input.rs index 97dc750c..cbb66035 100644 --- a/clients/android/native/src/session/input.rs +++ b/clients/android/native/src/session/input.rs @@ -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)], + }); +}