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 7e38588c..31e52f6b 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 @@ -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 @@ -475,16 +476,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 @@ -615,6 +636,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) } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt index 6f19cb14..f7e17be7 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DeviceGyro.kt @@ -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 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 08cdc313..08b94137 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 @@ -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 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 aa0f3e25..7e06b0d2 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 @@ -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() /** - * 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 = + Collections.newSetFromMap(ConcurrentHashMap()) + + /** + * 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. @@ -335,14 +358,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 = slots.keys.filter { it >= 0 } /** * A capture-link pad occupying a wire slot without an Android [InputDevice] — the as-is Steam @@ -485,6 +542,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 } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt new file mode 100644 index 00000000..568d934b --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/PadSensors.kt @@ -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() + + 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 }) + } + } +} diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt index b7468a82..092ec914 100644 --- a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/DeviceGyroTest.kt @@ -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) } } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt new file mode 100644 index 00000000..12867e75 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadSensorsTest.kt @@ -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) + } +}