feat(client/android): a Bluetooth controller's gyro stops going nowhere
Android had two motion sources and both of them are USB claims. DsCapture takes a Sony pad's HID interface away from the kernel; Sc2Capture does the same for a Steam Controller 2. Everything else — a DualSense, a DualShock 4, a Switch Pro, an 8BitDo, paired over Bluetooth — arrives as an ordinary InputDevice. Its buttons worked, its sticks worked, and its gyro was dead, silently, with no log line and nothing in the UI to suggest the pad had a sensor at all. That is not one controller, it is the whole class of controllers people actually pair to a phone. The platform has had the answer since Android 12: InputDevice.getSensorManager hands back a SensorManager scoped to that one controller, carrying its TYPE_GYROSCOPE and TYPE_ACCELEROMETER. PadSensors registers a listener per forwarded pad that has a gyroscope and sends the samples on that pad's wire index. Below API 31 it registers nothing and the pads behave exactly as they did. It is built on DeviceGyro's shape, because the phone mirror had already paid for these lessons. One dedicated HandlerThread, never the main one. Batching off (maxReportLatencyUs = 0) — batching would trade away precisely the latency gyro aim exists to avoid. 200 Hz requested, which is also the ceiling the framework grants an app without HIGH_SAMPLING_RATE_SENSORS, so asking for more would only be capped. And a feed that lets go of a pad still alive parks its rotation at zero first: the host holds motion as state and re-emits it in every virtual-pad report, so an angular velocity left behind is a pad that rotates forever. Two writers on one pad's motion is the failure this program has spent the day unpicking, so the coordination is explicit in three places. A USB capture wins: DsCapture.startUsb already calls releaseDevice at claim time, that closes the slot, and the close now also takes the sensor listeners off — the claim makes the InputDevice vanish anyway, but going through the explicit teardown is what makes the ordering deterministic instead of a race against the platform's own removal callback. The phone-gyro mirror stands down: registering flips a bit the router reports through padHasOwnMotion, which DeviceGyro re-reads on every sample and answers with its own zero park. And a pad with an accelerometer but no gyroscope is deliberately NOT taken — it could only send gravity while pinning rotation at zero, on a pad the mirror is otherwise entitled to speak for, which is the same fight in a quieter costume. The wire units are measured fact (punktfunk_core::input::gamepad: 20 LSB/deg·s, 10000 LSB/g), and they now live in exactly one place on this client: Gamepad.motionGyroWire / motionAccelWire, which DeviceGyro was hand-inlining a second copy of. The gyro program's first finding was a client sending 40x hot because a second copy of a number had drifted, and the merge that followed found a sender nobody remembered to correct. One function, both callers. THE AXIS FRAME ON THIS PATH IS NOT VERIFIED, and the mapping is deliberately straight through rather than guessed at. What is known: the wire is a unit passthrough into a virtual DualSense report, and that report's frame was measured over raw HID on 2026-08-07 as (Right, Up, Backward-toward-the-player) carrying (pitch, yaw, roll), right-handed — which is why the USB path forwards the pad's own order un-remapped and is correct to. Android documents its sensor frame for a handheld device as +x right, +y up, +z out of the face, the same frame once "the face" is read as the one the player looks at. So straight through is what the documentation implies. What nobody has done is put a Bluetooth DualSense in front of the platform sensor framework and compare — those numbers come through a HID driver and InputFlinger's sensor mapper, either of which could permute or negate without saying so. A plausible-looking wrong remap is exactly the bug this program keeps finding, so the code says unverified and names the measurement that settles it, and each feed logs its first converted sample so the cheapest half of that measurement — which slot gravity lands on with the pad flat and still — costs a logcat line. PadSensorsTest pins the scale, the clamp, the rounding and the straight-through order, mutation-checked four ways: 20 to 16 fails gyroScaleFromRadiansPerSecond and straightThroughFrame, reversing the axis order fails straightThroughFrame, truncating instead of rounding fails roundsToNearestNotTowardZero, and negating the accel fails restingPadIsTheHostNeutral. Its frame expectations are written to change together with any remap that lands, not to be edited around one. GamepadRouter needs Android and a live JNI handle and there is no Robolectric here, so its half is argued in comments beside the code, as DsCapture's claim ordering already is. Gates: kit 65 tests (58 before, plus 7), app 67 unchanged, 0 failures, read out of the JUnit XML rather than off a green build.
This commit is contained in:
@@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadFeedback
|
||||
import io.unom.punktfunk.kit.GamepadRouter
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.PadSensors
|
||||
import io.unom.punktfunk.kit.Sc2Capture
|
||||
import io.unom.punktfunk.kit.SessionEndReason
|
||||
import io.unom.punktfunk.kit.VideoDecoders
|
||||
@@ -459,16 +460,36 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
// "Gyro from this phone" (opt-in): this device's IMU speaks for controller 1's motion
|
||||
// while wire pad 0 is a controller without a gyro of its own — the rumble mirror's
|
||||
// sibling, data flowing the other way. The mirror gates itself per sample (it stands
|
||||
// down whenever a capture link — USB DualSense / SC2, pads with a real IMU — holds
|
||||
// pad 0), so it composes with the captures below without coordination here.
|
||||
// down whenever pad 0's controller has motion of its own — a capture link below, or a
|
||||
// pad whose own sensors PadSensors is reading), so it composes without coordination here.
|
||||
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
|
||||
DeviceGyro(context, handle, router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A Bluetooth controller's OWN gyro, through the platform sensor framework (API 31+):
|
||||
// a BT DualSense / DS4 / Switch Pro / 8BitDo is an ordinary InputDevice, so none of the
|
||||
// capture links below ever sees it and its motion used to go nowhere at all. No separate
|
||||
// setting — this is the pad's own IMU doing what the pad is for, and unlike the USB
|
||||
// captures it claims nothing; forwarding being off is the only thing that silences it.
|
||||
val padSensors = if (initialSettings.gamepadForwarding) {
|
||||
PadSensors(router).also { it.start() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Free a disconnected controller's rumble/lights bindings promptly (else the open lights
|
||||
// session leaks until the session ends). The router owns hot-plug; the feedback owns the binds.
|
||||
router.onSlotClosed = feedback::onDeviceRemoved
|
||||
// session leaks until the session ends), and take its sensor listeners off with it — the
|
||||
// same callback also fires when a USB capture below CLAIMS the pad, which is what keeps
|
||||
// the claimed pad from being fed motion twice. The router owns hot-plug; the feedback owns
|
||||
// the binds. Assigned before the captures are constructed, so their claims land on it.
|
||||
router.onSlotClosed = { deviceId ->
|
||||
feedback.onDeviceRemoved(deviceId)
|
||||
padSensors?.onSlotClosed(deviceId)
|
||||
}
|
||||
// The other edge: a controller that arrives (or first speaks) mid-session gets its sensors
|
||||
// read too. The pads already connected were swept by PadSensors.start() above — both run
|
||||
// on the main thread with nothing between them, so no controller falls through the gap.
|
||||
router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) }
|
||||
// Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an
|
||||
// already-paired BLE one — and forward its raw reports; the host mirrors a real
|
||||
// 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back
|
||||
@@ -599,6 +620,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
|
||||
feedback.sink = null
|
||||
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
|
||||
phoneGyro?.stop() // join the sensor thread + park pad 0's rotation at zero, same ordering rule
|
||||
// After the mirror, so it cannot resume writing pad 0 in the gap when a pad's own
|
||||
// sensors let go of it; before the router is released, so the parks still find slots.
|
||||
padSensors?.stop()
|
||||
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) } }
|
||||
|
||||
@@ -11,7 +11,6 @@ import android.os.HandlerThread
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The opt-in phone-gyro mirror ("Gyro from this phone", off by default): while wire pad 0 is a
|
||||
@@ -33,10 +32,10 @@ import kotlin.math.roundToInt
|
||||
* host's virtual pad never keeps integrating an angular velocity this device stopped
|
||||
* producing (the gyro-sweep "stale angular velocity re-sent forever" failure mode).
|
||||
*
|
||||
* Units are the wire contract (mirrors `pf-client-core`'s constants): gyro rad/s → 20 LSB/°·s,
|
||||
* accel m/s² → g → 10000 LSB/g. Android's accelerometer reads specific force (+1 g on the up
|
||||
* axis at rest), which is the DualSense report's own convention — no sign flip. The one thing
|
||||
* the phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* Units are the wire contract, converted by [Gamepad.motionGyroWire] / [Gamepad.motionAccelWire] —
|
||||
* the same two functions [PadSensors] uses, so a scale this client ever has to correct is corrected
|
||||
* once for every sender rather than once per sender that someone remembers. The one thing the
|
||||
* phone adds is a frame remap: sensors report in the device's natural-portrait frame, while
|
||||
* the wire wants the controller frame the player sees (x right, y up, z out of the screen), so
|
||||
* each sample is rotated by the current display rotation — a phone clipped landscape must yaw
|
||||
* when the player yaws, not roll. The matrix is derived and pinned by `DeviceGyroTest`;
|
||||
@@ -64,7 +63,7 @@ class DeviceGyro(
|
||||
private val thread = HandlerThread("pf-phone-gyro")
|
||||
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample). */
|
||||
private val lastAccel = intArrayOf(0, ACCEL_LSB_PER_G, 0)
|
||||
private val lastAccel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
|
||||
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
|
||||
private var wasWriting = false
|
||||
@@ -103,10 +102,7 @@ class DeviceGyro(
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> {
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
for (i in 0..2) {
|
||||
lastAccel[i] = (v[i] / GRAVITY * ACCEL_LSB_PER_G)
|
||||
.roundToInt().coerceIn(-32768, 32767)
|
||||
}
|
||||
for (i in 0..2) lastAccel[i] = Gamepad.motionAccelWire(v[i])
|
||||
}
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
|
||||
@@ -124,9 +120,9 @@ class DeviceGyro(
|
||||
val v = remap(rotation, event.values[0], event.values[1], event.values[2])
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, 0,
|
||||
(v[0] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
(v[1] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
(v[2] * GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767),
|
||||
Gamepad.motionGyroWire(v[0]),
|
||||
Gamepad.motionGyroWire(v[1]),
|
||||
Gamepad.motionGyroWire(v[2]),
|
||||
lastAccel[0], lastAccel[1], lastAccel[2],
|
||||
)
|
||||
}
|
||||
@@ -149,17 +145,12 @@ class DeviceGyro(
|
||||
context.getSystemService(SensorManager::class.java)
|
||||
?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null
|
||||
|
||||
/** ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz). */
|
||||
private const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/** The wire contract (pf-client-core `GYRO_LSB_PER_RAD_S`): 20 LSB/°·s from rad/s. */
|
||||
const val GYRO_LSB_PER_RAD_S = 20f * 180f / Math.PI.toFloat()
|
||||
|
||||
/** The wire contract (pf-client-core `ACCEL_LSB_PER_G`). */
|
||||
const val ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** pf-client-core's `G`. */
|
||||
const val GRAVITY = 9.80665f
|
||||
/**
|
||||
* ~200 Hz — between the sensor's usual FASTEST (~250-500 Hz) and GAME (~50 Hz), and also
|
||||
* the ceiling the framework grants an app without `HIGH_SAMPLING_RATE_SENSORS` (API 31+),
|
||||
* so asking for more would only be silently capped. Shared with [PadSensors].
|
||||
*/
|
||||
internal const val SAMPLING_PERIOD_US = 5000
|
||||
|
||||
/**
|
||||
* Rotate one device-frame vector (rotation rate or acceleration — both transform the
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Android gamepad capture → punktfunk/1 gamepad wire (the `input.rs::gamepad` contract; the host
|
||||
@@ -54,6 +55,31 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// Motion wire units — must equal punktfunk-core `input.rs::gamepad::MOTION_*`. Every motion
|
||||
// sender on this client goes through the two converters below, so a scale that ever has to
|
||||
// change changes in ONE place: the gyro program's first finding was a client sending 40× hot
|
||||
// because a second copy of the number had drifted.
|
||||
const val MOTION_GYRO_LSB_PER_DEG_S = 20
|
||||
const val MOTION_ACCEL_LSB_PER_G = 10_000
|
||||
|
||||
/** Standard gravity, `punktfunk-core`'s `G` — the divisor that turns m/s² into g. */
|
||||
const val GRAVITY = 9.80665f
|
||||
|
||||
/** [MOTION_GYRO_LSB_PER_DEG_S] restated for Android's rad/s sensors: 1 rad/s ⇒ ~1145.9 raw. */
|
||||
const val MOTION_GYRO_LSB_PER_RAD_S = MOTION_GYRO_LSB_PER_DEG_S * 180f / Math.PI.toFloat()
|
||||
|
||||
/** One angular-rate component, Android's rad/s → the wire's signed-16 raw units. */
|
||||
fun motionGyroWire(radPerSec: Float): Int =
|
||||
(radPerSec * MOTION_GYRO_LSB_PER_RAD_S).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
/**
|
||||
* One acceleration component, Android's m/s² → the wire's signed-16 raw units. Android reports
|
||||
* specific force (the axis pointing up reads +1 g at rest), which is the DualSense report's own
|
||||
* convention — no sign flip, and a pad lying flat lands on the host's neutral +1 g exactly.
|
||||
*/
|
||||
fun motionAccelWire(mPerSecSq: Float): Int =
|
||||
(mPerSecSq / GRAVITY * MOTION_ACCEL_LSB_PER_G).roundToInt().coerceIn(-32768, 32767)
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*
|
||||
* Threading: slot mutation + dispatch run on the main thread (Android input dispatch and the
|
||||
* InputManager hot-plug callbacks both land there). [deviceForPad] is read from the feedback poll
|
||||
* threads, so the slot table is a [ConcurrentHashMap].
|
||||
* threads, [padPresent]/[padHasOwnMotion] from the phone-gyro thread and [deviceMotion] from the
|
||||
* pad-sensor thread, so the slot table is a [ConcurrentHashMap].
|
||||
*/
|
||||
class GamepadRouter(
|
||||
context: Context,
|
||||
@@ -85,12 +87,33 @@ class GamepadRouter(
|
||||
private val slots = ConcurrentHashMap<Int, Slot>()
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown.
|
||||
* `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble /
|
||||
* lights bindings are released promptly instead of leaking until the feedback threads stop.
|
||||
* deviceIds whose own gyro [PadSensors] is currently reading — see [setDeviceHasSensorMotion].
|
||||
* Written on the main thread, read from the phone-gyro thread, hence a concurrent set.
|
||||
*/
|
||||
private val sensorDevices: MutableSet<Int> =
|
||||
Collections.newSetFromMap(ConcurrentHashMap<Int, Boolean>())
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug, a capture link's
|
||||
* [releaseDevice] claim, or session teardown. `StreamScreen` wires this to
|
||||
* `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / lights bindings are
|
||||
* released promptly instead of leaking until the feedback threads stop, and to
|
||||
* [PadSensors.onSlotClosed] so the controller's own sensor listeners come off with it.
|
||||
*/
|
||||
var onSlotClosed: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) with the deviceId whenever a slot opens for a REAL controller — the
|
||||
* hot-plug callback or the first input from a pad the session started without. Not fired for
|
||||
* [openExternal]: a capture link's pad has no [InputDevice] behind it and streams motion from
|
||||
* its own IMU already. `StreamScreen` wires this to [PadSensors.onSlotOpened].
|
||||
*
|
||||
* Slots opened in `init` (every controller already connected) predate any assignment here, so
|
||||
* a listener must sweep [forwardedDevices] once when it starts. Both happen on the main thread
|
||||
* inside one composition block, so nothing can slip between the sweep and the assignment.
|
||||
*/
|
||||
var onSlotOpened: ((deviceId: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller
|
||||
* leaves the stream. `StreamScreen` wires this to the deliberate-quit exit.
|
||||
@@ -324,14 +347,48 @@ class GamepadRouter(
|
||||
fun padPresent(pad: Int): Boolean = slots.values.any { it.index == pad }
|
||||
|
||||
/**
|
||||
* Whether wire pad [pad] is held by a capture-link slot ([ExternalPad] — USB DualSense /
|
||||
* SC2), whose motion arrives from the pad's OWN IMU. The phone-gyro mirror stands down for
|
||||
* those: two motion writers on one wire pad would fight. Synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]); real [InputDevice] ids are positive. Read from the phone-gyro thread
|
||||
* (the slot table is concurrent).
|
||||
* Whether wire pad [pad]'s motion already comes from the controller's OWN IMU — either a
|
||||
* capture-link slot ([ExternalPad] — USB DualSense / SC2; synthetic ids are negative
|
||||
* ([EXTERNAL_ID_BASE]), real [InputDevice] ids positive), or a real controller whose gyro
|
||||
* [PadSensors] is reading through the platform sensor framework (a Bluetooth DualSense /
|
||||
* Switch Pro / 8BitDo). The phone-gyro mirror stands down for both: two motion writers on one
|
||||
* wire pad would fight, and the pad's own IMU is the one attached to the player's hands.
|
||||
* Read from the phone-gyro thread (both tables are concurrent).
|
||||
*/
|
||||
fun padHasOwnMotion(pad: Int): Boolean =
|
||||
slots.any { (id, slot) -> slot.index == pad && id < 0 }
|
||||
slots.any { (id, slot) -> slot.index == pad && (id < 0 || id in sensorDevices) }
|
||||
|
||||
/**
|
||||
* Declare (or withdraw) that real controller [deviceId] is sourcing its own rotation — see
|
||||
* [padHasOwnMotion]. Called by [PadSensors] as it registers and unregisters listeners, on the
|
||||
* main thread; read from the phone-gyro thread, hence the concurrent set. Keyed by device
|
||||
* rather than by pad index so a controller that changes wire index (a lower one freed up while
|
||||
* it was captured) carries the fact with it.
|
||||
*/
|
||||
fun setDeviceHasSensorMotion(deviceId: Int, has: Boolean) {
|
||||
if (has) sensorDevices.add(deviceId) else sensorDevices.remove(deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* One motion sample from real controller [deviceId]'s own sensors, on whatever wire index its
|
||||
* slot currently holds — [ExternalPad.motion] for pads the input stack still owns. Silently
|
||||
* drops when the slot is gone (unplugged, or claimed by a capture link between the sensor
|
||||
* callback and here) rather than writing to an index that may already belong to someone else.
|
||||
* Called from [PadSensors]' sensor thread.
|
||||
*/
|
||||
fun deviceMotion(deviceId: Int, gyro: IntArray, accel: IntArray) {
|
||||
val slot = slots[deviceId] ?: return
|
||||
if (!forwarding) return
|
||||
NativeBridge.nativeSendPadMotion(
|
||||
handle, slot.index,
|
||||
gyro[0], gyro[1], gyro[2],
|
||||
accel[0], accel[1], accel[2],
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot of the REAL controllers currently forwarded, as deviceIds — the set [PadSensors]
|
||||
* sweeps at start for the pads that were already connected when the session opened. */
|
||||
fun forwardedDevices(): List<Int> = slots.keys.filter { it >= 0 }
|
||||
|
||||
/**
|
||||
* A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam
|
||||
@@ -452,6 +509,9 @@ class GamepadRouter(
|
||||
if (forwarding) NativeBridge.nativeSendGamepadArrival(handle, pref, index)
|
||||
val slot = Slot(index, Gamepad.AxisMapper(handle, index))
|
||||
slots[dev.id] = slot
|
||||
// After the table holds the slot, so a listener that sends on this device the moment it is
|
||||
// told ([PadSensors]) finds an index to send on rather than dropping its first samples.
|
||||
onSlotOpened?.invoke(dev.id)
|
||||
return slot
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Motion from a controller the Android input stack owns — the Bluetooth pads.
|
||||
*
|
||||
* Before this, the only motion sources on Android were the capture links: [DsCapture] (a Sony pad
|
||||
* claimed over USB, raw HID) and [Sc2Capture] (Steam Controller 2 passthrough). A DualSense, a
|
||||
* DualShock 4, a Switch Pro or an 8BitDo paired over BLUETOOTH is neither — it arrives as an
|
||||
* ordinary [InputDevice], its buttons and sticks work, and its gyro was silently dead. That is a
|
||||
* whole class of controller with no motion at all.
|
||||
*
|
||||
* Android 12 (API 31) exposes those sensors: [InputDevice.getSensorManager] hands back a
|
||||
* [android.hardware.SensorManager] scoped to that one controller, carrying the usual
|
||||
* TYPE_GYROSCOPE / TYPE_ACCELEROMETER. This class registers a listener per forwarded controller
|
||||
* that has a gyroscope, converts each sample to wire units, and sends it on that pad's wire index
|
||||
* through [GamepadRouter.deviceMotion]. Below API 31 nothing is registered and the class is inert —
|
||||
* those pads keep working, minus motion, exactly as they did.
|
||||
*
|
||||
* It follows [DeviceGyro] (the phone-gyro mirror) wherever the two solve the same problem:
|
||||
* - samples ride ONE dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
|
||||
* — sensor batching would trade away the exact latency gyro aim exists to avoid, and the main
|
||||
* thread is where Compose recomposition lives;
|
||||
* - a feed torn down while its wire pad is still alive parks the rotation at zero first, because
|
||||
* the host holds motion as STATE and re-emits it in every virtual-pad report: an angular
|
||||
* velocity left behind reads as a pad rotating forever (the gyro sweep's "stale rate re-sent
|
||||
* forever" finding).
|
||||
*
|
||||
* One writer per pad, three ways:
|
||||
* 1. A USB capture claims the physical device away from the input stack; [DsCapture.startUsb]
|
||||
* calls [GamepadRouter.releaseDevice] at claim time, which closes the slot, which fires
|
||||
* `onSlotClosed`, which lands on [onSlotClosed] here and unregisters. The claim also makes the
|
||||
* controller's [InputDevice] vanish outright, so even a reopened slot would find nothing to
|
||||
* register — but the explicit teardown is what makes the ordering deterministic instead of a
|
||||
* race against the platform's own removal callback.
|
||||
* 2. The phone-gyro mirror stands down: registering flips
|
||||
* [GamepadRouter.setDeviceHasSensorMotion], [GamepadRouter.padHasOwnMotion] reports it, and
|
||||
* [DeviceGyro] re-reads that gate on every sample (sending its own zero park on the edge).
|
||||
* 3. Exactly one feed exists per deviceId — [onSlotOpened] is idempotent, and it is the only
|
||||
* thing that ever constructs one.
|
||||
*
|
||||
* Frame: see [gyroToWire] — the mapping is straight through, and NOT yet verified on hardware.
|
||||
*/
|
||||
class PadSensors(private val router: GamepadRouter) {
|
||||
|
||||
/** One controller's live sensor feed: its listener state and the accel it pairs with each
|
||||
* rotation. Its arrays belong to the sensor thread; [stop] reads them only after the join. */
|
||||
private inner class Feed(private val deviceId: Int) : SensorEventListener {
|
||||
/** Latest converted accel, paired with each gyro send (the wire fuses both per sample).
|
||||
* Starts at the host's neutral — 1 g on the up axis, NOT [0,0,0], which is free fall. */
|
||||
private val accel = intArrayOf(0, Gamepad.MOTION_ACCEL_LSB_PER_G, 0)
|
||||
private val gyro = IntArray(3)
|
||||
|
||||
/** Whether any rotation has gone out on this pad — gates the park on teardown, so a pad
|
||||
* that never sent motion is not handed a sample it did not earn. */
|
||||
@Volatile
|
||||
var wroteMotion = false
|
||||
private set
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ACCELEROMETER -> accelToWire(event.values, accel)
|
||||
Sensor.TYPE_GYROSCOPE -> {
|
||||
gyroToWire(event.values, gyro)
|
||||
// One line per controller per session, on the first sample that carries both
|
||||
// planes: it is the cheapest possible version of the frame measurement
|
||||
// [gyroToWire] asks for. Hold the pad flat and still while a stream starts and
|
||||
// the accel triple says which slot gravity lands on — the one thing that
|
||||
// settles whether the straight-through mapping is right.
|
||||
if (!wroteMotion) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"controller $deviceId first motion sample: " +
|
||||
"gyro ${gyro.joinToString()} accel ${accel.joinToString()}",
|
||||
)
|
||||
}
|
||||
wroteMotion = true
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Zero rotation, last-known accel — "at rest", not free fall. */
|
||||
fun park() {
|
||||
gyro.fill(0)
|
||||
router.deviceMotion(deviceId, gyro, accel)
|
||||
}
|
||||
}
|
||||
|
||||
/** deviceId → live feed. Concurrent: the main thread mutates it while the sensor thread is
|
||||
* running (hot-plug, a capture link's claim). */
|
||||
private val feeds = ConcurrentHashMap<Int, Feed>()
|
||||
|
||||
private val thread = HandlerThread("pf-pad-sensors")
|
||||
private var handler: Handler? = null
|
||||
|
||||
/**
|
||||
* Start the sensor thread and attach to every controller the router already forwards — the
|
||||
* pads connected before the session opened, which will never fire a hot-plug callback.
|
||||
* Everything after that arrives through [onSlotOpened]. Main thread.
|
||||
*/
|
||||
fun start() {
|
||||
if (!supported()) return
|
||||
thread.start()
|
||||
handler = Handler(thread.looper)
|
||||
for (deviceId in router.forwardedDevices()) onSlotOpened(deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot opened for real controller [deviceId] — attach if it has a gyroscope of its own.
|
||||
* Idempotent, and a no-op before [start] or on a platform without the API. Main thread, from
|
||||
* [GamepadRouter.onSlotOpened].
|
||||
*/
|
||||
fun onSlotOpened(deviceId: Int) {
|
||||
val h = handler ?: return
|
||||
if (feeds.containsKey(deviceId)) return
|
||||
// API 31+ only — getSensorManager does not exist below it. Re-checked here rather than
|
||||
// relying on start()'s gate, so the entry point is safe on its own terms.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
|
||||
val dev = InputDevice.getDevice(deviceId) ?: return
|
||||
// Declared non-null: a controller with no sensors gets an empty manager, not a null one.
|
||||
val sm = dev.sensorManager
|
||||
// A gyroscope is the entry price; the accelerometer alone does not buy a feed. The rotation
|
||||
// is what gyro aim is for, and an accel-only feed would send gravity while pinning rotation
|
||||
// at zero on a pad the phone-gyro mirror is otherwise entitled to speak for — precisely the
|
||||
// two-writers-on-one-pad fight this program has spent its day unpicking. Such a pad stays
|
||||
// on the mirror's terms instead, where at least the accel agrees with the gyro beside it.
|
||||
// Nothing found here is not proof the pad has no IMU. A DualSense's motion arrives on its
|
||||
// own evdev node, and whether InputReader merges that node onto the gamepad InputDevice
|
||||
// (shared descriptor) or leaves it standing alone is the platform's business, not ours —
|
||||
// and a standalone one is exactly what GamepadRouter.isForwardable filters out, so this
|
||||
// would never see it. Android 12's own controller-sensor documentation cites the DualShock
|
||||
// 4 and DualSense, which says the merge happens; it is not something this code can assert.
|
||||
// If a Bluetooth Sony pad ever turns up here with no gyroscope, THAT is the thing to check.
|
||||
val gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
|
||||
val feed = Feed(deviceId)
|
||||
feeds[deviceId] = feed
|
||||
// ~200 Hz requested, zero report latency: batching is poison for gyro aim, and 200 Hz is
|
||||
// what the framework grants an app without HIGH_SAMPLING_RATE_SENSORS anyway.
|
||||
sm.registerListener(feed, gyroSensor, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
|
||||
sm.registerListener(feed, it, DeviceGyro.SAMPLING_PERIOD_US, 0, h)
|
||||
}
|
||||
// The pad sources its own rotation from here on → the phone-gyro mirror stands down for it.
|
||||
router.setDeviceHasSensorMotion(deviceId, true)
|
||||
Log.i(TAG, "controller $deviceId (${dev.name}) has a gyro — forwarding its motion")
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot for [deviceId] closed — unplug, session teardown, or a capture link claiming the
|
||||
* device. Unregister and hand the pad back to the phone-gyro mirror. Main thread, from
|
||||
* [GamepadRouter.onSlotClosed].
|
||||
*
|
||||
* No park-at-zero here, on purpose: the router removed the slot BEFORE invoking the callback
|
||||
* and has already sent that pad's Remove, so the host tore the virtual pad down and there is no
|
||||
* latched rotation left to clear — while writing to a wire index that is free again would be
|
||||
* addressing whoever claims it next. [stop] is the case where the pad outlives the feed.
|
||||
*/
|
||||
fun onSlotClosed(deviceId: Int) {
|
||||
unregister(deviceId)
|
||||
router.setDeviceHasSensorMotion(deviceId, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister every listener, join the sensor thread, then park at zero each pad that was
|
||||
* rotating. Call BEFORE the router is released and the session handle freed — the same
|
||||
* teardown ordering rule as the feedback poll threads and [DeviceGyro.stop]. The parks come
|
||||
* AFTER the join for two reasons: a sample still in flight would re-latch the rotation just
|
||||
* cleared, and the join is what publishes the sensor thread's writes to this one.
|
||||
*/
|
||||
fun stop() {
|
||||
val parked = feeds.keys.toList().mapNotNull { id -> unregister(id)?.let { id to it } }
|
||||
for ((deviceId, _) in parked) router.setDeviceHasSensorMotion(deviceId, false)
|
||||
thread.quitSafely()
|
||||
runCatching { thread.join() }
|
||||
handler = null
|
||||
for ((_, feed) in parked) if (feed.wroteMotion) feed.park()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop [deviceId]'s listeners, returning the feed that held them (null if there was none).
|
||||
* Safe for a controller that is already gone: the sensor manager is reached through the
|
||||
* [InputDevice], and a vanished device simply leaves nothing to unregister — the platform has
|
||||
* stopped calling the listener either way.
|
||||
*/
|
||||
private fun unregister(deviceId: Int): Feed? {
|
||||
val feed = feeds.remove(deviceId) ?: return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
InputDevice.getDevice(deviceId)?.sensorManager?.unregisterListener(feed)
|
||||
}
|
||||
return feed
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadSensors"
|
||||
|
||||
/** Whether this platform can read a controller's own sensors at all (API 31+). */
|
||||
fun supported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* One gyroscope sample (Android: rad/s) → the wire's three signed-16 components, in place.
|
||||
*
|
||||
* ⚠ **THE AXIS FRAME IS NOT VERIFIED ON THIS PATH.** The wire is a unit passthrough into a
|
||||
* virtual DualSense report, and that report's frame was MEASURED on 2026-08-07 over raw
|
||||
* HID: slot 0 = Right (pitch), slot 1 = Up (yaw), slot 2 = Backward, toward the player
|
||||
* (roll), right-handed. Android documents its sensor frame for a handheld device as +x
|
||||
* right, +y up, +z out of the face — the same frame once "the face" is read as the one the
|
||||
* player looks at, which is what the platform means by a controller's own sensor. So
|
||||
* straight through is the mapping the documentation implies, and it is what this does.
|
||||
* What nobody has DONE is put a Bluetooth DualSense in front of the platform sensor
|
||||
* framework and compare: those numbers come through a HID driver and InputFlinger's sensor
|
||||
* mapper, either of which could permute or negate without saying so.
|
||||
*
|
||||
* The measurement that settles it is the DualSense one repeated on this path — pad flat
|
||||
* and still, then three labelled rotations:
|
||||
* - at rest, gravity must land as +1 g on ACCEL slot 1 (not 0, not 2);
|
||||
* - yaw clockwise seen from above ⇒ GYRO slot 1 negative;
|
||||
* - pitch the far edge down ⇒ slot 0 negative;
|
||||
* - roll right-side down ⇒ slot 2 negative.
|
||||
* Any disagreement is a remap, and it belongs HERE with its own expectations in
|
||||
* `PadSensorsTest` — not spread across callers, and not guessed at in advance.
|
||||
*/
|
||||
fun gyroToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionGyroWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
|
||||
/**
|
||||
* One accelerometer sample (Android: m/s², specific force) → the wire's three signed-16
|
||||
* components, in place. Same unverified frame as [gyroToWire] and the same straight-through
|
||||
* mapping; the sign needs no flip, because Android and the DualSense report agree that the
|
||||
* axis pointing up reads +1 g at rest (see [Gamepad.motionAccelWire]).
|
||||
*/
|
||||
fun accelToWire(values: FloatArray, out: IntArray) {
|
||||
for (i in 0..2) out[i] = Gamepad.motionAccelWire(values.getOrElse(i) { 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,13 @@ class DeviceGyroTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** The wire contract, shared with pf-client-core / the Swift client: 20 LSB/°·s means
|
||||
* 1 rad/s ⇒ ~1145.9 raw; 1 g ⇒ 10000 raw. */
|
||||
/** The wire contract, shared with pf-client-core / the Swift client and now with every other
|
||||
* Android motion sender ([Gamepad.motionGyroWire]): 20 LSB/°·s means 1 rad/s ⇒ ~1145.9 raw;
|
||||
* 1 g ⇒ 10000 raw. */
|
||||
@Test
|
||||
fun wireUnitConstants() {
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), DeviceGyro.GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, DeviceGyro.GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, DeviceGyro.ACCEL_LSB_PER_G)
|
||||
assertEquals(20f * 180f / Math.PI.toFloat(), Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0f)
|
||||
assertEquals(1145.9156f, Gamepad.MOTION_GYRO_LSB_PER_RAD_S, 0.001f)
|
||||
assertEquals(10_000, Gamepad.MOTION_ACCEL_LSB_PER_G)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package io.unom.punktfunk.kit
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the unit scaling and the axis mapping of the controller-sensor path ([PadSensors]) and the
|
||||
* shared converters it goes through ([Gamepad.motionGyroWire] / [Gamepad.motionAccelWire]). Pure
|
||||
* JVM — the two `*ToWire` functions take plain float arrays and touch no Android class.
|
||||
*
|
||||
* The scale is MEASURED FACT (`punktfunk_core::input::gamepad`: 20 LSB/°·s, 10000 LSB/g) and must
|
||||
* not drift. The axis mapping is straight through and NOT yet verified against hardware — see
|
||||
* [PadSensors.gyroToWire] for the measurement that would settle it. [straightThroughFrame] exists
|
||||
* to make a future remap a deliberate, visible edit rather than a quiet one.
|
||||
* Run: `./gradlew :kit:testDebugUnitTest`.
|
||||
*/
|
||||
class PadSensorsTest {
|
||||
private fun gyro(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.gyroToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
private fun accel(x: Float, y: Float, z: Float) =
|
||||
IntArray(3).also { PadSensors.accelToWire(floatArrayOf(x, y, z), it) }
|
||||
|
||||
/** 20 LSB/°·s from Android's rad/s: π rad/s is exactly 180 °/s, so exactly 3600 raw. */
|
||||
@Test
|
||||
fun gyroScaleFromRadiansPerSecond() {
|
||||
assertEquals(3600, gyro(Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(-3600, gyro(-Math.PI.toFloat(), 0f, 0f)[0])
|
||||
assertEquals(1146, gyro(1f, 0f, 0f)[0]) // 1 rad/s ⇒ 1145.9156, rounded
|
||||
assertEquals(0, gyro(0f, 0f, 0f)[0])
|
||||
}
|
||||
|
||||
/** 10000 LSB/g from Android's m/s²: standard gravity is exactly 1 g. Android reports specific
|
||||
* force, so a pad at rest reads +1 g on the axis pointing up — no sign flip anywhere. */
|
||||
@Test
|
||||
fun accelScaleFromMetresPerSecondSquared() {
|
||||
assertEquals(10_000, accel(0f, Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(-10_000, accel(0f, -Gamepad.GRAVITY, 0f)[1])
|
||||
assertEquals(0, accel(0f, 0f, 0f)[1])
|
||||
}
|
||||
|
||||
/** A controller lying flat and still lands exactly on the host's neutral for a virtual
|
||||
* DualSense — 1 g on wire slot 1 (`punktfunk-core` `MOTION_NEUTRAL_ACCEL = [0, 10000, 0]`),
|
||||
* not the [0,0,0] that means free fall. */
|
||||
@Test
|
||||
fun restingPadIsTheHostNeutral() {
|
||||
assertArrayEquals(intArrayOf(0, 10_000, 0), accel(0f, Gamepad.GRAVITY, 0f))
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame: component i of the sensor sample becomes component i of the wire triple, for both
|
||||
* planes, with no permutation and no negation. UNVERIFIED against hardware — if a Bluetooth
|
||||
* DualSense says otherwise, the remap goes into [PadSensors.gyroToWire] and this test changes
|
||||
* with it. Distinct magnitudes per axis so a swap or a flip cannot cancel out.
|
||||
*/
|
||||
@Test
|
||||
fun straightThroughFrame() {
|
||||
assertArrayEquals(intArrayOf(1146, 2292, 3438), gyro(1f, 2f, 3f))
|
||||
assertArrayEquals(
|
||||
intArrayOf(10_000, 20_000, -30_000),
|
||||
accel(Gamepad.GRAVITY, 2f * Gamepad.GRAVITY, -3f * Gamepad.GRAVITY),
|
||||
)
|
||||
}
|
||||
|
||||
/** Both planes clamp to signed 16 bits rather than wrapping — a flick past 1638 °/s or a knock
|
||||
* past 3.27 g saturates, where a wrap would send a full-speed rotation the other way. */
|
||||
@Test
|
||||
fun clampsToSigned16() {
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), gyro(100f, -100f, 1e9f))
|
||||
assertArrayEquals(intArrayOf(32767, -32768, 32767), accel(1000f, -1000f, 1e9f))
|
||||
}
|
||||
|
||||
/** Rounds to nearest rather than truncating: a truncating converter loses up to a whole LSB
|
||||
* off every sample, always toward zero, and a gyro whose every sample is biased the same way
|
||||
* is a gyro that drifts. */
|
||||
@Test
|
||||
fun roundsToNearestNotTowardZero() {
|
||||
assertEquals(1, gyro(0.0006f, 0f, 0f)[0]) // 0.688 raw — truncation would say 0
|
||||
assertEquals(-1, gyro(-0.0006f, 0f, 0f)[0])
|
||||
assertEquals(1, accel(0.0007f, 0f, 0f)[0]) // 0.714 raw
|
||||
}
|
||||
|
||||
/** A sensor that hands back fewer than three components (or none — the framework reuses one
|
||||
* array across types) contributes zero rather than throwing on the sensor thread. */
|
||||
@Test
|
||||
fun shortSampleIsZeroFilled() {
|
||||
val out = IntArray(3) { 7 }
|
||||
PadSensors.gyroToWire(floatArrayOf(Math.PI.toFloat()), out)
|
||||
assertArrayEquals(intArrayOf(3600, 0, 0), out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user