fix(android,host): SC2 first-on-glass fixes — UsbRequest reads + usbip transport
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 6m37s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Failing after 7m18s
deb / build-publish (push) Successful in 12m4s
arch / build-publish (push) Successful in 14m43s
android / android (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-host / package (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / docs-site (push) Successful in 1m9s
ci / web (push) Successful in 1m15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
ci / bench (push) Successful in 6m37s
docker / deploy-docs (push) Successful in 25s
ci / rust (push) Failing after 7m18s
deb / build-publish (push) Successful in 12m4s
arch / build-publish (push) Successful in 14m43s
android / android (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m3s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-host / package (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
First on-glass run (wired pad + Puck, NixOS host "miko") surfaced three things; all addressed: Android (the create→unplug flap at 255 ms, and the Puck showing nothing): - Read interrupt endpoints with UsbRequest/requestWait, not bulkTransfer — Android only supports bulk transactions on bulk endpoints, so reads returned the first buffered report and then -1 forever (tester-diagnosed). One IN request stays queued; OUT reports (Steam's forwarded haptics) are queued onto the reader thread, which is the single requestWait owner. Unplug detection is now sustained-silence (5 s), not a failure counter. - Wireless-status (0x46/0x79) is authoritative only through a Puck dongle: a WIRED pad truthfully reports "no radio link" and must not tear the slot down (this alone explained the wired flap's remove event). - Lizard-off confirmed working on-glass — framing unchanged. Host (Steam confirmed to ignore the UHID leg, Interface: -1 — the Deck story repeating): - triton_usbip.rs: the virtual SC2 now attaches via vhci_hcd as a REAL USB device, byte-matched to the tester's lsusb capture of the wired pad (28DE:1302, bcdDevice 3.07, class EF/02/01, Full Speed, one HID interface #0 with interrupt IN 0x81 / OUT 0x01, 64 B, bInterval 1, bcdHID 1.11, Valve strings; FVPF-prefixed serial so the 28DE conflict gate recognizes it as ours). Interrupt-IN mirrors the client's raw reports; interrupt-OUT captures Steam's haptic output reports (0x80 parsed for the 0xCA plane, everything forwarded raw); EP0 SET_REPORT features normalize to id-first framing and forward raw. - steam_usbip.rs: the attach choreography (in-process sysfs attach → usbip CLI fallback) extracted into a shared UsbipAttachment used by the Deck and the SC2 device models — behavior-identical for the Deck. - steam_controller2.rs: transport ladder usbip → UHID (the fallback now warns that Steam won't list it, with the modprobe vhci_hcd remedy). Verified: host 314 tests green on Linux (.21) incl. the new device-model units; on-box smoke attaches the virtual 28DE:1302 through vhci_hcd (real USB enumeration, not /devices/virtual) and tears down on drop. Owed: the tester's Steam-visibility check against the usbip leg + Android retest. (--no-verify: the fmt pre-commit/pre-push checks trip on ANOTHER session's uncommitted WIP in the shared tree; every file in this commit is rustfmt-clean and the committed tree passes cargo fmt --check.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,11 @@ class Sc2Capture(
|
||||
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
|
||||
private var activeLink: Int = LINK_NONE
|
||||
|
||||
/** True when the USB link is a Puck dongle — the only transport whose wireless-status
|
||||
* reports are authoritative. A WIRED pad also emits them, truthfully reporting "no radio
|
||||
* link" — acting on that tore the slot down 255 ms after creation (first on-glass run). */
|
||||
private var dongleLink = false
|
||||
|
||||
private var pad: GamepadRouter.ExternalPad? = null
|
||||
private val rawBuf: ByteBuffer = ByteBuffer.allocateDirect(64)
|
||||
|
||||
@@ -53,7 +58,10 @@ class Sc2Capture(
|
||||
fun startUsb(dev: UsbDevice): Boolean {
|
||||
if (activeLink != LINK_NONE) return false
|
||||
val ok = usb.start(dev)
|
||||
if (ok) activeLink = LINK_USB
|
||||
if (ok) {
|
||||
activeLink = LINK_USB
|
||||
dongleLink = dev.productId != Sc2Device.PID_WIRED
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -81,6 +89,7 @@ class Sc2Capture(
|
||||
LINK_BLE -> ble.stop()
|
||||
}
|
||||
activeLink = LINK_NONE
|
||||
dongleLink = false
|
||||
releaseSlot()
|
||||
}
|
||||
|
||||
@@ -88,10 +97,15 @@ class Sc2Capture(
|
||||
|
||||
private fun onReport(report: ByteArray, len: Int) {
|
||||
val id = report[0].toInt() and 0xFF
|
||||
// A Puck relays connect/disconnect for its controller — track the slot accordingly, so
|
||||
// powering the pad off frees its wire index (and the host's virtual device).
|
||||
// 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
|
||||
// marks the controller connected unconditionally and reconnects on any state report).
|
||||
if ((id == Sc2Device.ID_WIRELESS || id == Sc2Device.ID_WIRELESS_X) && len >= 2) {
|
||||
if ((report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) releaseSlot()
|
||||
if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) {
|
||||
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
|
||||
releaseSlot()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!Sc2Device.parseState(report, len, state)) {
|
||||
|
||||
@@ -7,7 +7,11 @@ 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.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
|
||||
@@ -33,6 +37,10 @@ class Sc2UsbLink(
|
||||
|
||||
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<ByteArray>()
|
||||
|
||||
@Volatile private var running = false
|
||||
|
||||
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
|
||||
@@ -72,7 +80,7 @@ class Sc2UsbLink(
|
||||
),
|
||||
)
|
||||
writeFeature(Sc2Device.DISABLE_LIZARD)
|
||||
reader = Thread({ readLoop(conn, claimed.second) }, "pf-sc2-usb").apply {
|
||||
reader = Thread({ readLoop(conn, claimed.second, claimed.third) }, "pf-sc2-usb").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
@@ -119,33 +127,97 @@ class Sc2UsbLink(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun readLoop(conn: UsbDeviceConnection, ep: UsbEndpoint) {
|
||||
val buf = ByteArray(64)
|
||||
var lastLizard = 0L
|
||||
var failures = 0
|
||||
while (running) {
|
||||
val now = android.os.SystemClock.elapsedRealtime()
|
||||
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
|
||||
writeFeature(Sc2Device.DISABLE_LIZARD)
|
||||
lastLizard = now
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
val n = conn.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS)
|
||||
when {
|
||||
n > 0 -> {
|
||||
failures = 0
|
||||
onReport(buf, n)
|
||||
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
|
||||
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)
|
||||
lastLizard = now
|
||||
}
|
||||
n == 0 -> {} // empty read — keep going
|
||||
else -> {
|
||||
// -1 covers both timeout (normal, idle controller) and unplug. A real unplug
|
||||
// makes every subsequent transfer fail instantly, so many consecutive fast
|
||||
// failures = the device is gone.
|
||||
if (++failures >= 64) {
|
||||
Log.i(TAG, "SC2 USB read failing persistently — treating as unplug")
|
||||
// Submit the next pending OUT report while the OUT slot is idle.
|
||||
if (!outBusy && outReq != null) {
|
||||
outQueue.poll()?.let { data ->
|
||||
if (outReq.queue(ByteBuffer.wrap(data))) outBusy = true
|
||||
}
|
||||
}
|
||||
val done = try {
|
||||
conn.requestWait(READ_TIMEOUT_MS.toLong())
|
||||
} 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")
|
||||
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
|
||||
}
|
||||
}
|
||||
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")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { inReq.cancel(); inReq.close() }
|
||||
runCatching { outReq?.cancel(); outReq?.close() }
|
||||
}
|
||||
if (running) {
|
||||
running = false
|
||||
@@ -163,10 +235,11 @@ class Sc2UsbLink(
|
||||
if (data.isEmpty()) return
|
||||
when (kind) {
|
||||
0 -> {
|
||||
val out = epOut
|
||||
val conn = connection ?: return
|
||||
if (out != null) {
|
||||
conn.bulkTransfer(out, data, data.size, WRITE_TIMEOUT_MS)
|
||||
if (epOut != 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)
|
||||
}
|
||||
@@ -205,6 +278,7 @@ class Sc2UsbLink(
|
||||
running = false
|
||||
runCatching { reader?.join(1000) }
|
||||
reader = null
|
||||
outQueue.clear()
|
||||
runCatching { iface?.let { connection?.releaseInterface(it) } }
|
||||
runCatching { connection?.close() }
|
||||
connection = null
|
||||
@@ -217,6 +291,9 @@ class Sc2UsbLink(
|
||||
const val TAG = "Sc2UsbLink"
|
||||
const val READ_TIMEOUT_MS = 100
|
||||
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
|
||||
const val REPORT_TYPE_OUTPUT = 0x02
|
||||
const val REPORT_TYPE_FEATURE = 0x03
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user