diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 64e4ab1e..ab7536eb 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -92,6 +92,13 @@ object Gamepad { private val PID_STEAMDECK = setOf(0x1205) private val PID_STEAMCONTROLLER = setOf(0x1102, 0x1142) + // Steam Controller 2: wired (0x1302), BLE (0x1303), Puck dongles (0x1304/0x1305). Normally + // the Sc2 capture link CLAIMS the USB device (detaching it from the input stack), so this + // entry only fires in the degraded path — capture off / permission denied — where the pad + // surfaces as a plain InputDevice. Declaring SC2 there lets the host build the matching + // virtual pad from the typed plane instead of falling back to Xbox 360. + private val PID_STEAMCONTROLLER2 = setOf(0x1302, 0x1303, 0x1304, 0x1305) + // Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All // behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte. private val PID_XBOXONE = setOf( @@ -118,6 +125,7 @@ object Gamepad { vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER + vid == VID_VALVE && pid in PID_STEAMCONTROLLER2 -> PREF_STEAMCONTROLLER2 vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO else -> PREF_XBOX360 } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt index f01a2be9..c8ceb609 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt @@ -45,6 +45,9 @@ class Sc2Capture( private var wireButtons = 0 private val lastAxis = IntArray(6) { Int.MIN_VALUE } + /** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */ + private val seenIds = HashSet() + /** First attached SC2/Puck USB device, for the permission flow. */ fun findUsbDevice(): UsbDevice? = usb.findDevice() @@ -97,6 +100,7 @@ class Sc2Capture( private fun onReport(report: ByteArray, len: Int) { val id = report[0].toInt() and 0xFF + if (seenIds.add(id)) Log.i(TAG, "SC2 report id=0x%02x seen (len=%d)".format(id, len)) // Wireless status: authoritative ONLY through a Puck dongle (powering the pad off frees // its wire index + the host's virtual device). A wired/BLE pad emits it too — truthfully // saying "no radio link" — and must NOT tear the slot down (SDL's wired path likewise 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 7ab77279..baf74cc5 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,6 +1,9 @@ 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 @@ -8,6 +11,7 @@ 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 @@ -15,31 +19,57 @@ import java.util.concurrent.TimeoutException /** * USB transport for a Steam Controller 2 — wired (`28DE:1302`) or through the wireless Puck - * dongle (`1304`/`1305`, controller on interfaces 2..5). Claims the Valve vendor interface - * (detaching any kernel/OS consumer), runs a read loop on its interrupt-IN endpoint, 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`). 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. * - * One controller per link in v1: on a dongle the first claimable controller interface wins - * (multi-pad-per-Puck is a follow-up). + * **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 + * only interface 2 read silence while Android's input stack kept the others (the round-2 + * 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). */ class Sc2UsbLink( - context: Context, + private val context: Context, private val onReport: (report: ByteArray, len: Int) -> Unit, private val 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 iface: UsbInterface? = null - private var epIn: UsbEndpoint? = null - private var epOut: UsbEndpoint? = 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 - - /** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread — see - * [readLoop] for why only one thread may drive this connection's [UsbRequest]s. */ - private val outQueue = ConcurrentLinkedQueue() + private var detachReceiver: BroadcastReceiver? = null @Volatile private var running = false @@ -49,9 +79,8 @@ class Sc2UsbLink( } /** - * Claim [dev] and start the read + lizard-heartbeat loop. The caller has already obtained - * USB permission ([UsbManager.hasPermission]). Returns false when no controller interface - * could be claimed. + * 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)) { @@ -62,25 +91,52 @@ class Sc2UsbLink( Log.e(TAG, "openDevice failed for ${dev.deviceName}") return false } - val claimed = claimControllerInterface(dev, conn) ?: run { + 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 - iface = claimed.first - epIn = claimed.second - epOut = claimed.third + device = dev + claims = claimed running = true Log.i( TAG, - "SC2 USB link up: PID=0x%04x iface=%d in=0x%02x out=%s".format( - dev.productId, claimed.first.id, claimed.second.address, - claimed.third?.let { "0x%02x".format(it.address) } ?: "control", + "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) } ?: "-", + ) + }, ), ) - writeFeature(Sc2Device.DISABLE_LIZARD) - reader = Thread({ readLoop(conn, claimed.second, claimed.third) }, "pf-sc2-usb").apply { + // 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 { sendFeature(conn, it.iface.id, Sc2Device.DISABLE_LIZARD) } + reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply { isDaemon = true start() } @@ -88,30 +144,24 @@ class Sc2UsbLink( } /** - * Pick the controller interface: vendor-defined (0xFF) class with an interrupt/bulk IN - * endpoint, restricted to interfaces 2..5 on a Puck dongle (the SDL-documented controller - * range — the other interfaces are the dongle's own control/lizard endpoints). + * 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 claimControllerInterface( - dev: UsbDevice, - conn: UsbDeviceConnection, - ): Triple? { + private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List { val dongle = dev.productId != Sc2Device.PID_WIRED - val candidates = (0 until dev.interfaceCount) - .map { dev.getInterface(it) } - .filter { !dongle || it.id in Sc2Device.DONGLE_IFACES } - .sortedByDescending { - when (it.interfaceClass) { - 0xFF -> 2 // vendor-defined first — the Valve gamepad interface - UsbConstants.USB_CLASS_HID -> 1 - else -> 0 - } - } - for (candidate in candidates) { + 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 (i in 0 until candidate.endpointCount) { - val ep = candidate.getEndpoint(i) + 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 @@ -119,105 +169,113 @@ class Sc2UsbLink( if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep } if (inEp == null) continue - // force=true detaches the kernel/OS driver — while claimed, the controller vanishes - // from Android's own input stack (no double input alongside our capture). - if (conn.claimInterface(candidate, true)) return Triple(candidate, inEp, outEp) - Log.w(TAG, "could not claim iface ${candidate.id}, trying next") + if (conn.claimInterface(iface, true)) { + out.add(Claim(iface, inEp, outEp)) + } else { + Log.w(TAG, "could not claim iface ${iface.id}") + } } - return null + return out } /** - * The read loop, built on [UsbRequest] — NOT `bulkTransfer()`: Android only supports bulk - * transactions on bulk endpoints, and the SC2's endpoints are INTERRUPT. `bulkTransfer()` - * returned the first (already-buffered) report and then `-1` forever, which the first - * on-glass run surfaced as a 250 ms create→unplug flap. One IN request stays queued at all - * times; OUT writes (Steam's forwarded rumble) are queued from [writeRaw]'s thread onto - * [outQueue] and submitted HERE, because `requestWait` returns ANY completed request on the - * connection — a second thread waiting would steal the reader's completions. + * 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, epIn: UsbEndpoint, epOut: UsbEndpoint?) { - val inReq = UsbRequest() - if (!inReq.initialize(conn, epIn)) { - Log.e(TAG, "UsbRequest.initialize(IN) failed") - if (running) { - running = false - onClosed() + 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 outReq = epOut?.let { ep -> - UsbRequest().takeIf { it.initialize(conn, ep) } - ?: run { Log.w(TAG, "UsbRequest.initialize(OUT) failed — output reports dropped"); null } - } - val inBuf = ByteBuffer.allocate(64) val scratch = ByteArray(64) - var outBusy = false - var lastLizard = 0L - var quietSince = 0L // elapsedRealtime of the first silent/failed wait in the streak; 0 = healthy - var reports = 0L + var lastLizard = android.os.SystemClock.elapsedRealtime() + var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak try { - inBuf.clear() - if (!inReq.queue(inBuf)) { - Log.e(TAG, "queue(IN) failed") - return - } while (running) { val now = android.os.SystemClock.elapsedRealtime() if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) { - writeFeature(Sc2Device.DISABLE_LIZARD) + // Refresh on every claimed interface until one is known-active (the dongle + // relays it per-slot); afterwards the active one suffices. + val target = activeClaim + if (target != null) sendFeature(conn, target.iface.id, Sc2Device.DISABLE_LIZARD) + else live.forEach { sendFeature(conn, it.iface.id, Sc2Device.DISABLE_LIZARD) } lastLizard = now } - // Submit the next pending OUT report while the OUT slot is idle. - if (!outBusy && outReq != null) { + // 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 (outReq.queue(ByteBuffer.wrap(data))) outBusy = true + if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true } } val done = try { - conn.requestWait(READ_TIMEOUT_MS.toLong()) + conn.requestWait(READ_TIMEOUT_MS) } catch (_: TimeoutException) { - // Normal while the pad is quiet; a SUSTAINED silence is the unplug signal - // (a healthy SC2 streams state continuously at its 1 kHz interval). - if (quietSince == 0L) quietSince = now - if (now - quietSince >= UNPLUG_AFTER_MS) { - Log.i(TAG, "SC2 USB silent for ${now - quietSince} ms (after $reports reports) — treating as unplug") + // 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 } - when { - done === inReq -> { - if (quietSince != 0L) { - Log.i(TAG, "SC2 USB reads recovered after ${now - quietSince} ms") - quietSince = 0L - } - val n = inBuf.position() - if (n > 0) { - inBuf.flip() - inBuf.get(scratch, 0, n) - if (reports++ == 0L) { - Log.i(TAG, "SC2 USB first report: id=0x%02x len=%d".format(scratch[0].toInt() and 0xFF, n)) - } - onReport(scratch, n) - } - inBuf.clear() - if (!inReq.queue(inBuf)) { - Log.i(TAG, "re-queue(IN) failed — treating as unplug") - break + 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) } - done === outReq -> outBusy = false - done == null -> { - // requestWait error — the connection is gone (unplug / claim revoked). - Log.i(TAG, "SC2 USB requestWait error (after $reports reports) — treating as unplug") + 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 { - runCatching { inReq.cancel(); inReq.close() } - runCatching { outReq?.cancel(); outReq?.close() } + finishReader(claims) } if (running) { running = false @@ -225,9 +283,18 @@ class Sc2UsbLink( } } + 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 + } + } + /** * Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80` - * rumble & friends — interrupt-OUT when the interface has one, else a `SET_REPORT(Output)` + * 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. */ @@ -235,7 +302,7 @@ class Sc2UsbLink( if (data.isEmpty()) return when (kind) { 0 -> { - if (epOut != null) { + 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() @@ -244,56 +311,62 @@ class Sc2UsbLink( setReport(REPORT_TYPE_OUTPUT, data) } } - 1 -> writeFeature(data) + 1 -> setReport(REPORT_TYPE_FEATURE, data) } } - private fun writeFeature(data: ByteArray) { - 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) + } + + 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). + * "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 setReport(type: Int, data: ByteArray) { - val conn = connection ?: return - val ifId = iface?.id ?: return + 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, - ifId, + ifaceId, payload, payload.size, WRITE_TIMEOUT_MS, ) } - /** Stop the read loop and release the interface. Idempotent; does not fire [onClosed]. */ + /** 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() - runCatching { iface?.let { connection?.releaseInterface(it) } } + activeClaim = null + for (c in claims) runCatching { connection?.releaseInterface(c.iface) } + claims = emptyList() runCatching { connection?.close() } connection = null - iface = null - epIn = null - epOut = null + device = null } private companion object { const val TAG = "Sc2UsbLink" - const val READ_TIMEOUT_MS = 100 + const val READ_TIMEOUT_MS = 100L const val WRITE_TIMEOUT_MS = 250 - /** Sustained read-failure window treated as an unplug (a streaming pad reports every - * few ms; even an idle one shouldn't go silent for this long). */ - const val UNPLUG_AFTER_MS = 5000L + /** 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 }