Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) } }
Expand Down
1 change: 1 addition & 0 deletions feature/connection/domain/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ plugins {
dependencies {
api(project(":core:model"))
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Loading