feat(client/android): the phone's gyro can speak for a gyro-less pad
apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / bun-nix (pull_request) Successful in 30s
android / android (pull_request) Successful in 4m5s
ci / rust-arm64 (pull_request) Successful in 4m1s
ci / docs-site (pull_request) Successful in 1m26s
ci / rust (pull_request) Successful in 10m54s

Opt-in "Gyro from this phone" (gyro_on_phone, off by default): this
device's IMU sources wire pad 0's motion while that pad is a controller
with no motion source of its own. On Android that gate is exact — the only
pads that forward motion are the capture links (USB DualSense / SC2,
claimed as ExternalPads), so the mirror stands down per sample whenever
GamepadRouter.padHasOwnMotion(0) says a capture link holds the index, and
sends nothing while pad 0 has no slot at all (motion never creates a host
pad). "Rumble on this phone"'s sibling, data flowing the other way: same
read-once-at-attach settings plumbing, same hardware-gated rows in the
touch and controller settings (a TV box has no gyroscope to mirror from).

DeviceGyro registers TYPE_GYROSCOPE + TYPE_ACCELEROMETER at ~200 Hz on a
dedicated HandlerThread with batching disabled (maxReportLatencyUs = 0 —
batching is poison for gyro aim), converts with the wire contract shared
with pf-client-core (rad/s → 20 LSB/°·s, m/s² → g → 10000 LSB/g; Android's
accelerometer already reads specific force, the DualSense report's own
convention), and rotates each sample from the natural-portrait sensor
frame into the controller frame by display rotation — a phone clipped
landscape yaws when the player yaws instead of rolling. The remap matrix
and unit constants are pinned by DeviceGyroTest.

A stand-down edge (capture link claims pad 0, or session teardown) sends
one zero-gyro sample so the host's virtual pad never keeps integrating an
angular velocity this device stopped producing — the gyro sweep's
stale-rotation latch, avoided by construction here.
This commit is contained in:
2026-08-07 11:38:31 +02:00
parent 1f54b75c5c
commit 55aba3e936
9 changed files with 310 additions and 6 deletions
@@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
@@ -126,6 +127,8 @@ fun GamepadSettingsScreen(
val context = LocalContext.current
// Gates the "Rumble on this phone" row — a TV box has no body vibrator to mirror onto.
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
// Gates "Gyro from this phone" the same way — a TV box has no gyroscope to mirror from.
val hasGyroscope = remember { DeviceGyro.available(context) }
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
@@ -159,7 +162,7 @@ fun GamepadSettingsScreen(
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
// interface remote-navigably. The strings branch on it.
val tv = remember { isTvDevice(context) }
val allRows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) +
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
// Which section is showing, and where each one's focus was when it was last left — a detour
// into another tab shouldn't lose your place.
@@ -445,12 +448,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
}
/** Build the console settings rows from the current [Settings], writing through [update].
* [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the
* AV1 codec entry (see `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one
* tab at a time. */
* [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this
* phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see
* `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */
internal fun buildSettingsRows(
s: Settings,
hasBodyVibrator: Boolean,
hasGyroscope: Boolean,
av1Capable: Boolean,
update: (Settings) -> Unit,
): List<GpRow> {
@@ -598,6 +602,18 @@ internal fun buildSettingsRows(
} else {
null
},
// The rumble mirror's sibling, data flowing the other way — needs a gyroscope to
// mirror FROM, which a TV box lacks.
if (hasGyroscope) {
toggle(
"phoneGyro", GpTab.CONTROLLER, null, "Gyro from this phone",
"When the controller has no gyro of its own, send this phone's motion " +
"sensors as controller 1's — for clip-on pads without one.",
s.gyroOnPhone,
) { update(s.copy(gyroOnPhone = it)) }
} else {
null
},
) + listOf(
// NOT gated on the vibrator (the bug A2 fixed in the touch settings): an SC2 capture has
// nothing to do with this device's motor, and a TV box is where it matters most.
@@ -158,6 +158,16 @@ data class Settings(
* toggle is hidden on devices without a vibrator (TVs), where this would be a silent no-op.
*/
val rumbleOnPhone: Boolean = false,
/**
* Opt-in: use this phone's own gyroscope as controller 1's motion when the forwarded pad has
* none of its own — for clip-on gamepads without an IMU, where the phone body moves with the
* player's hands. The rumble mirror's sibling, data flowing the other way. Off by default;
* read once per session by StreamScreen (it starts a [io.unom.punktfunk.kit.DeviceGyro] only
* when set), and the mirror stands down by itself whenever wire pad 0 is fed by a capture
* link (USB DualSense / SC2 — pads with a real gyro). The toggle is hidden on devices
* without a gyroscope (TVs), where this would be a silent no-op.
*/
val gyroOnPhone: Boolean = false,
/**
* Capture a Steam Controller 2 (wired / Puck dongle over USB, or an already-paired BLE pad)
@@ -300,6 +310,7 @@ class SettingsStore(context: Context) {
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
gyroOnPhone = prefs.getBoolean(K_GYRO_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
padHaptics = prefs.getBoolean(K_PAD_HAPTICS, true),
@@ -340,6 +351,7 @@ class SettingsStore(context: Context) {
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_GYRO_ON_PHONE, s.gyroOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putBoolean(K_PAD_HAPTICS, s.padHaptics)
@@ -390,6 +402,7 @@ class SettingsStore(context: Context) {
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_GYRO_ON_PHONE = "gyro_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_PAD_HAPTICS = "pad_haptics"
@@ -77,6 +77,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.security.KnownHostStore
@@ -888,6 +889,18 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
onCheckedChange = { on -> update(s.copy(rumbleOnPhone = on)) },
)
}
// The rumble mirror's sibling, data flowing the other way: needs a gyroscope to
// mirror FROM — a TV box has none, so the row would be a silent no-op there.
val hasGyroscope = remember { DeviceGyro.available(context) }
if (hasGyroscope) {
ToggleRow(
title = "Gyro from this phone",
subtitle = "When the controller has no gyro, send this phone's motion " +
"sensors as controller 1's",
checked = s.gyroOnPhone,
onCheckedChange = { on -> update(s.copy(gyroOnPhone = on)) },
)
}
// NOT gated on the vibrator: SC2 passthrough is a USB/BLE capture that has nothing to do
// with rumbling this device's body, and the gate hid the toggle on exactly the machines
// that most want it — TV boxes, where a Steam Controller 2 is the whole input story.
@@ -67,6 +67,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DeviceGyro
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
@@ -455,6 +456,16 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
router,
deviceVibrator = if (initialSettings.rumbleOnPhone) deviceBodyVibrator(context) else null,
).also { it.start() }
// "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.
val phoneGyro = if (initialSettings.gyroOnPhone && initialSettings.gamepadForwarding) {
DeviceGyro(context, handle, 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
@@ -587,6 +598,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
feedback.onHidRaw = null
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
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) } }
@@ -123,7 +123,9 @@ class GamepadPaletteTest {
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(Settings(), hasBodyVibrator = true, av1Capable = true) {}
val rows = buildSettingsRows(
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
@@ -137,7 +139,9 @@ class GamepadPaletteTest {
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(s, hasBodyVibrator = false, av1Capable = false) { s = it }
fun rows() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
@@ -24,6 +24,7 @@ class GamepadSettingsRowsTest {
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
) { sink += it }
@@ -0,0 +1,179 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.os.Handler
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
* controller with no motion source of its own, THIS device's IMU speaks for it on the rich-input
* motion plane for clip-on and third-party pads that ship without a gyro, where the phone body
* is rigidly attached to (or simply is) the thing in the player's hands. [GamepadFeedback]'s
* rumble-on-phone mirror with the data flowing the other way.
*
* On Android the only motion sources are the capture links (USB DualSense / SC2 pads with a
* real IMU, claimed as [GamepadRouter.ExternalPad]s), so the stand-down rule is exactly
* [GamepadRouter.padHasOwnMotion]: when a capture link holds pad 0, the mirror sends nothing
* two motion writers on one wire pad would fight. It also sends nothing while pad 0 has no slot
* at all (motion never creates a host pad; a controller must have arrived first).
*
* Two properties this class enforces itself:
* - samples ride a dedicated [HandlerThread] with batching disabled (`maxReportLatencyUs = 0`)
* sensor batching would trade the exact latency gyro aim exists to avoid;
* - a stand-down edge (capture link claims pad 0, or [stop]) sends ONE zero-gyro sample, so the
* 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/ 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
* 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`;
* correctable in one place if on-glass says otherwise.
*/
class DeviceGyro(
context: Context,
private val handle: Long,
private val router: GamepadRouter,
) : SensorEventListener {
private val sensorManager: SensorManager? =
context.getSystemService(SensorManager::class.java)
/** For the live rotation; null on contexts without a display association (then portrait). */
private val display: Display? = runCatching {
if (Build.VERSION.SDK_INT >= 30) {
context.display
} else {
@Suppress("DEPRECATION")
context.getSystemService(WindowManager::class.java)?.defaultDisplay
}
}.getOrNull()
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)
/** Whether the last gyro event actually went to pad 0 — the stand-down zero-send edge. */
private var wasWriting = false
/** Register the listeners; a device without a gyroscope makes this a no-op. */
fun start() {
val sm = sensorManager ?: return
val gyro = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ?: return
thread.start()
val h = Handler(thread.looper)
// ~200 Hz requested (the framework clamps to what the hardware offers), zero report
// latency: batching is poison for gyro aim.
sm.registerListener(this, gyro, SAMPLING_PERIOD_US, 0, h)
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
sm.registerListener(this, it, SAMPLING_PERIOD_US, 0, h)
}
}
/**
* Unregister and join the sensor thread, then park the host pad's rotation at zero if this
* mirror was the live writer. Call BEFORE the router is released / the handle freed
* teardown-ordered like the feedback threads.
*/
fun stop() {
sensorManager?.unregisterListener(this)
thread.quitSafely()
runCatching { thread.join() }
if (wasWriting) {
wasWriting = false
sendZero()
}
}
override fun onSensorChanged(event: SensorEvent) {
val rotation = display?.rotation ?: Surface.ROTATION_0
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)
}
}
Sensor.TYPE_GYROSCOPE -> {
// The write gate, per sample: pad 0 must exist (motion never creates a pad)
// and must not be a capture link's (its own IMU is streaming).
val write = router.padPresent(0) && !router.padHasOwnMotion(0)
if (!write) {
// Stand-down edge: never leave the last angular velocity latched host-side.
if (wasWriting) {
wasWriting = false
sendZero()
}
return
}
wasWriting = true
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),
lastAccel[0], lastAccel[1], lastAccel[2],
)
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
/** Zero rotation, last-known accel — "at rest", not free-fall. */
private fun sendZero() {
NativeBridge.nativeSendPadMotion(
handle, 0, 0, 0, 0, lastAccel[0], lastAccel[1], lastAccel[2],
)
}
companion object {
/** Whether this device can source motion at all gates the settings rows (a TV box
* without an IMU would make the toggle a silent no-op, the rumble mirror's rule). */
fun available(context: Context): Boolean =
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
/**
* Rotate one device-frame vector (rotation rate or acceleration both transform the
* same way under an in-plane rotation) into the controller frame for [rotation]
* ([Surface].ROTATION_*). Sensors report in the natural-portrait frame (+x right edge,
* +y top, +z out of the screen); the controller frame keeps +z (the screen always faces
* the player) and rotates x/y to mean "player's right" and "player's up". ROTATION_90 =
* the device physically turned counter-clockwise, top to the player's LEFT.
*/
fun remap(rotation: Int, x: Float, y: Float, z: Float): FloatArray = when (rotation) {
Surface.ROTATION_90 -> floatArrayOf(-y, x, z) // top left: right = bottom, up = +x
Surface.ROTATION_270 -> floatArrayOf(y, -x, z) // top right: right = top, up = x
Surface.ROTATION_180 -> floatArrayOf(-x, -y, z)
else -> floatArrayOf(x, y, z)
}
}
}
@@ -320,6 +320,19 @@ class GamepadRouter(
return null
}
/** Whether ANY live slot currently holds wire pad [pad]. Read from the phone-gyro thread. */
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).
*/
fun padHasOwnMotion(pad: Int): Boolean =
slots.any { (id, slot) -> slot.index == pad && id < 0 }
/**
* A capture-link pad occupying a wire slot without an Android [InputDevice] the as-is Steam
* Controller 2 passthrough (USB/BLE claimed directly, invisible to the input stack). Shares
@@ -0,0 +1,53 @@
package io.unom.punktfunk.kit
import android.view.Surface
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pins the phone-gyro mirror's devicecontroller frame remap and its wire-unit constants
* ([DeviceGyro]). Pure JVM: [Surface]'s ROTATION_* are compile-time constants and remap is
* plain math. The matrix is derived (like the wire scale constants) if on-glass says an axis
* is wrong, fix [DeviceGyro.remap] AND these expectations together.
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class DeviceGyroTest {
/** A distinct value per axis so a swapped or flipped component can't cancel out. */
private fun remap(rotation: Int) = DeviceGyro.remap(rotation, 1f, 2f, 3f).toList()
@Test
fun naturalPortraitIsIdentity() = assertEquals(listOf(1f, 2f, 3f), remap(Surface.ROTATION_0))
@Test
fun upsideDownFlipsInPlane() = assertEquals(listOf(-1f, -2f, 3f), remap(Surface.ROTATION_180))
/** ROTATION_90 = device turned counter-clockwise, top to the player's LEFT:
* player-right = device-bottom (y), player-up = device-right (+x); z never changes. */
@Test
fun rotation90TopLeft() = assertEquals(listOf(-2f, 1f, 3f), remap(Surface.ROTATION_90))
/** ROTATION_270 = top to the player's RIGHT: player-right = +y, player-up = x. */
@Test
fun rotation270TopRight() = assertEquals(listOf(2f, -1f, 3f), remap(Surface.ROTATION_270))
/** Every remap stays a proper (right-handed) rotation: x̂ × ŷ = ẑ after mapping. */
@Test
fun handednessPreserved() {
for (r in listOf(
Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270,
)) {
val x = DeviceGyro.remap(r, 1f, 0f, 0f)
val y = DeviceGyro.remap(r, 0f, 1f, 0f)
assertEquals("left-handed remap at rotation $r", 1f, x[0] * y[1] - x[1] * y[0], 0f)
}
}
/** 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. */
@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)
}
}