diff --git a/README.md b/README.md index 4e24c62..04d9b11 100644 --- a/README.md +++ b/README.md @@ -393,6 +393,27 @@ the only type signal available before input starts streaming. Left Joy-Con's right-stick bytes are garbage (ignored); Right Joy-Con's left-stick bytes are garbage. +### Stick Range and Centre + +The raw 12-bit sticks neither span `0x000..0xFFF` nor rest at the midpoint, and both vary per +controller and per axis. Measured on hardware: + +``` +travel: full left ~900 full right ~3400 full down ~890 full up ~3360 (half-span ~1250) +rest: left Joy-Con x 2080 y 2157 right Joy-Con x 2014 y 2022 +``` + +Rest is **not** the midpoint of travel, so it has to be sampled rather than inferred: those +extremes midpoint to 2150/2125, which matches neither controller. Treating 2048 as both centre and +half-span leaves full deflection at ~60% of range with a permanent 4-5% drift at rest, and skews +each direction oppositely. + +`StickCalibrator` therefore learns each axis' centre from the first still window after connect — +frozen afterwards, because a stick held at full deflection is perfectly still and would otherwise +be adopted as centre — and scales each direction by its own span, the same centre/below/above +triple the controller's factory calibration stores. Calibration is applied where packets are +parsed, so the live display, HID reports, and DSU all see the same corrected values. + --- ## HID Report Descriptor diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 38d12ba..e2386ff 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -98,6 +98,7 @@ class JoyconConnection( private val mainHandler = Handler(Looper.getMainLooper()) private val opQueue = GattOpQueue() + private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null private var writeChar: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null @@ -280,7 +281,7 @@ class JoyconConnection( private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { when (uuid) { NOTIFY_CHAR -> { - PacketParser.parse(data, side)?.let { _input.value = it } + PacketParser.parse(data, side)?.let { _input.value = stickCalibrator.calibrate(it) } if (!ledSentAfterFirstPacket && initComplete) { ledSentAfterFirstPacket = true mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } diff --git a/feature/connection/domain/build.gradle.kts b/feature/connection/domain/build.gradle.kts index 032b4d7..0440b59 100644 --- a/feature/connection/domain/build.gradle.kts +++ b/feature/connection/domain/build.gradle.kts @@ -5,4 +5,5 @@ plugins { dependencies { api(project(":core:model")) api(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt new file mode 100644 index 0000000..d7c209c --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt @@ -0,0 +1,110 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconInput + +/** + * Rescales one controller's raw stick readings onto the full 0..4095 range, centred on 2048, + * that every downstream consumer assumes. + * + * Measured on hardware (2026-09): the raw 12-bit sticks reach only about +-1250 LSB of travel + * (full left 900, full right 3400) and they do not rest at 2048 — left Joy-Con x 2080 / y 2157, + * right x 2014 / y 2022. Taking 2048 as both the centre and the half-span therefore leaves full + * deflection at roughly 60% of range with a permanent 4-5% drift at rest. + * + * Travel is asymmetric about rest, so each direction carries its own span — the same + * centre/below/above triple the controller's own factory calibration stores. Spans start at + * [seedHalfSpan] and only ever widen, so a stick reaches full tilt from the first packet and + * self-corrects to units that travel further. + * + * Centre is learned from the first still window after connect and then frozen. Gyro bias can be + * re-learned whenever the controller goes quiet, but a stick held at full deflection is perfectly + * still, so "no movement means at rest" would happily adopt full tilt as centre. + */ +class StickCalibrator( + restWindowSize: Int = DEFAULT_REST_WINDOW, + maxRestSpreadLsb: Int = DEFAULT_MAX_REST_SPREAD, + seedHalfSpan: Int = DEFAULT_SEED_HALF_SPAN, +) { + + private val leftX = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) + private val leftY = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) + private val rightX = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) + private val rightY = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) + + fun calibrate(input: JoyconInput): JoyconInput = input.copy( + stickX = leftX.rescale(input.stickX), + stickY = leftY.rescale(input.stickY), + rightStickX = rightX.rescale(input.rightStickX), + rightStickY = rightY.rescale(input.rightStickY), + ) + + private class Axis( + private val restWindowSize: Int, + private val maxRestSpreadLsb: Int, + seedHalfSpan: Int, + ) { + private var centre = CENTER + private var centreLearned = false + private var below = seedHalfSpan + private var above = seedHalfSpan + + private var count = 0 + private var sum = 0L + private var min = 0 + private var max = 0 + + fun rescale(raw: Int): Int { + learnCentre(raw) + val delta = raw - centre + val scaled = when { + delta > 0 -> { + above = maxOf(above, delta) + CENTER + delta * CENTER / above + } + delta < 0 -> { + below = maxOf(below, -delta) + CENTER + delta * CENTER / below + } + else -> CENTER + } + return scaled.coerceIn(0, MAX) + } + + private fun learnCentre(raw: Int) { + if (centreLearned) return + + if (count == 0) { + min = raw + max = raw + } else { + min = minOf(min, raw) + max = maxOf(max, raw) + } + sum += raw + count++ + if (count < restWindowSize) return + + if (max - min <= maxRestSpreadLsb) { + centre = (sum / count).toInt() + centreLearned = true + } + count = 0 + sum = 0 + } + } + + companion object { + private const val CENTER = 2048 + private const val MAX = 4095 + + // ~250 ms at the 120 Hz report rate: long enough that any deliberate stick movement + // blows the spread test, short enough that centre lands before the first menu input. + private const val DEFAULT_REST_WINDOW = 30 + private const val DEFAULT_MAX_REST_SPREAD = 32 + + // Smallest travel measured across the user's two Joy-Cons was ~1180 LSB; seeding just + // under that means full tilt saturates slightly early rather than falling short, and + // the widening in [Axis.rescale] recovers the exact span once the stick is rolled. + private const val DEFAULT_SEED_HALF_SPAN = 1150 + } +} diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt new file mode 100644 index 0000000..4723969 --- /dev/null +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt @@ -0,0 +1,106 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconInput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StickCalibratorTest { + + private val calibrator = StickCalibrator(restWindowSize = 4, maxRestSpreadLsb = 8) + + // Hardware-measured left Joy-Con: rests off-centre, travels ~1200 LSB each way. + private val restX = 2080 + private val fullLeft = 900 + private val fullRight = 3400 + + private fun rescale(x: Int): Int = calibrator.calibrate(JoyconInput(stickX = x)).stickX + + private fun settleAtRest(value: Int = restX) = repeat(4) { rescale(value) } + + @Test + fun `a stick resting off-centre reports dead centre once calibrated`() { + settleAtRest() + + assertEquals(2048, rescale(restX)) + } + + @Test + fun `full deflection reaches both ends of the range`() { + settleAtRest() + + assertEquals(4095, rescale(fullRight)) + assertEquals(0, rescale(fullLeft)) + } + + @Test + fun `each direction is scaled by its own span`() { + settleAtRest() + rescale(fullRight) + rescale(fullLeft) + + // Rest sits 1180 above full left and 1320 below full right, so the same raw + // distance from centre must not produce the same output on both sides. + val right = rescale(restX + 600) + val left = rescale(restX - 600) + assertTrue(right - 2048 < 2048 - left) + } + + @Test + fun `a stick held at full deflection is never adopted as centre`() { + settleAtRest() + + repeat(40) { rescale(fullRight) } + + assertEquals(4095, rescale(fullRight)) + } + + @Test + fun `a window with the stick moving is rejected and the next still one is used`() { + listOf(2080, 2600, 3100, 3400).forEach { rescale(it) } + val beforeSettling = rescale(restX) + + settleAtRest() + + assertNotEquals(2048, beforeSettling) + assertEquals(2048, rescale(restX)) + } + + @Test + fun `travel beyond the seeded span still maps to the end of the range`() { + val wide = StickCalibrator(restWindowSize = 4, maxRestSpreadLsb = 8, seedHalfSpan = 600) + repeat(4) { wide.calibrate(JoyconInput(stickX = restX)) } + + assertEquals(4095, wide.calibrate(JoyconInput(stickX = fullRight)).stickX) + } + + @Test + fun `sticks deflect before centre is learned`() { + assertTrue(rescale(fullRight) > 2048) + } + + @Test + fun `each axis calibrates independently`() { + repeat(4) { + calibrator.calibrate(JoyconInput(stickX = 2080, stickY = 2157, rightStickX = 2014)) + } + + val calibrated = calibrator.calibrate( + JoyconInput(stickX = 2080, stickY = 2157, rightStickX = 2014), + ) + assertEquals(2048, calibrated.stickX) + assertEquals(2048, calibrated.stickY) + assertEquals(2048, calibrated.rightStickX) + } + + @Test + fun `controllers with different resting centres both calibrate to centre`() { + val other = StickCalibrator(restWindowSize = 4, maxRestSpreadLsb = 8) + settleAtRest() + repeat(4) { other.calibrate(JoyconInput(stickX = 2014)) } + + assertEquals(2048, rescale(restX)) + assertEquals(2048, other.calibrate(JoyconInput(stickX = 2014)).stickX) + } +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt index ba1e06b..ca92dba 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt @@ -22,6 +22,7 @@ import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.StickBg import com.joegec.joycon2android.ui.theme.TextBright import com.joegec.joycon2android.ui.theme.TextDim +import kotlin.math.hypot @Composable internal fun StickCard( @@ -62,11 +63,19 @@ private fun StickCanvas( drawLine(CrosshairColor, Offset(c.x - r, c.y), Offset(c.x + r, c.y), Dimens.crosshairStroke) drawLine(CrosshairColor, Offset(c.x, c.y - r), Offset(c.x, c.y + r), Dimens.crosshairStroke) - val dot = Offset(c.x + nx * r, c.y - ny * r) + val dot = dotPosition(c, nx, ny, r - Dimens.stickDotRadius) drawCircle(color = dotColor, radius = Dimens.stickDotRadius, center = dot) } } +// Each axis is normalised against its own travel, so a full diagonal reaches 1 on both and lands +// outside the ring. The stick's gate is round, so it's the magnitude that clamps, not each axis. +private fun dotPosition(centre: Offset, nx: Float, ny: Float, travel: Float): Offset { + val magnitude = hypot(nx, ny) + val scale = if (magnitude > 1f) 1f / magnitude else 1f + return Offset(centre.x + nx * scale * travel, centre.y - ny * scale * travel) +} + @Composable private fun StickValues(x: Int, y: Int) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {