From b9b86c56fa1b6eba7403882a3a66464429c1657c Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 2 Sep 2026 19:00:22 +0100 Subject: [PATCH 1/2] Calibrate stick centre and travel per controller The raw 12-bit sticks only travel about +-1250 LSB and rest off-centre, and both vary per unit and per axis (left Joy-Con x 2080 / y 2157, right x 2014 / y 2022). Mapping them as (v - 2048) * 32767 / 2048 left full deflection at 56-66% of HID range with a permanent 4-5% drift at rest, so the analog stick moved characters slower than the d-pad's digital hat. StickCalibrator learns each axis' centre from the first still window after connect and scales each direction by its own span. It runs where packets are parsed, so the live display, HID reports, and DSU all see corrected values instead of each re-deriving the same wrong assumption. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 21 ++++ .../connection/JoyconConnection.kt | 3 +- feature/connection/domain/build.gradle.kts | 1 + .../connection/StickCalibrator.kt | 110 ++++++++++++++++++ .../connection/StickCalibratorTest.kt | 106 +++++++++++++++++ 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt create mode 100644 feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt 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) + } +} From c10998b0e42fc885cbda1a1c8cbf1f0149453233 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 2 Sep 2026 19:00:22 +0100 Subject: [PATCH 2/2] Clamp the stick display dot inside its ring Normalising each axis against its own travel means a full diagonal reaches 1 on both, which a square mapping draws at 1.41x the ring radius. The stick's gate is round, so clamp the magnitude rather than each axis, and draw within the ring instead of centring the dot on it. Co-Authored-By: Claude Opus 5 (1M context) --- .../connection/presentation/StickCard.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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) {