diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00a51cdc..14d55f57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ Add tests for new domain and data logic, and update any doc your change makes st BLE behaviour is very device-specific, so a good bug report includes: - Device model and Android version -- Controller type (Joy-Con 2 left / right / pair / Pro) and how it was connected +- Controller type (Joy-Con 2 left / right / pair, Pro or NSO GameCube) and how it was connected - What you expected vs. what happened - A relevant `adb logcat` snippet (the app logs under the `Joycon2` and `DsuServer` tags) diff --git a/README.md b/README.md index 0e785df1..3be04c12 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ as an ordinary gamepad. ## Features -- **Joy-Con 2 and Switch 2 Pro Controller** — connect several at once, GL/GR paddles included. +- **Joy-Con 2, Switch 2 Pro Controller and NSO GameCube controller** — connect several at once, GL/GR + paddles included. - **Up to 8 players** — each with a sideways Joy-Con, a pair, or a Pro Controller. - **A gamepad per player** — every app sees one standard controller per player, so multiplayer just works. @@ -35,7 +36,7 @@ as an ordinary gamepad. ### What you need - Android 7.0 (API 24) or newer, with Bluetooth LE -- Joy-Con 2 or a Switch 2 Pro Controller +- Joy-Con 2, a Switch 2 Pro Controller or an NSO GameCube controller - [Shizuku](https://shizuku.rikka.app/), for the virtual gamepad and emulator auto setup — DSU motion works without it @@ -58,7 +59,11 @@ as an ordinary gamepad. 1. Hold **SYNC** on the controller and tap **Scan**. 2. Assign each controller to a player — from the app, or with the Switch combos: **L** on one Joy-Con - and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller. + and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller + or GameCube controller. +3. If a controller shows as the wrong type, tap its type at the top of its card and pick the right one + before assigning it. The choice is remembered for that controller; **Auto** goes back to what it + reports. ### 3. Turn on the virtual gamepad @@ -96,12 +101,13 @@ the emulator afterwards — it only reads its config when it starts. | Card | Emulators | What it writes | |---|---|---| -| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks | +| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks; analog L/R for a GameCube controller | | DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion | The gamepad button beside **Set up** opens the mapping editor, with a card per connected player — tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A +GameCube controller has layouts of its own, with Z on its Z button and analog L/R in Dolphin. A target can take **several sources** — tick as many as you like, and any of them fires it. - **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads @@ -131,7 +137,7 @@ The layouts the app ships: | Layout | For | |---|---| | Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb | -| Joy-Con | The same, with the Joy-Con's own B as B, 1 and 2 on the shoulders so your thumb stays on the stick, and Recenter on the stick click | +| Joy-Con | The same, with the Joy-Con's own B as B, 1 and 2 on the shoulders so your thumb stays on the stick, and Recenter on the stick click. A GameCube controller has no stick click or −, so Recenter is on Y and − on X | | Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail | | Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z | @@ -242,6 +248,7 @@ shoulder buttons. | "Shizuku is not running" | Open Shizuku and start it | | "Shizuku permission denied" | Shizuku → Apps → allow Joycon2Android | | Controller not found | Hold SYNC again and move closer | +| Controller shows as the wrong type (stick turned sideways, no right stick) | Tap its type at the top of its card, pick the right one, then assign it | | Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent | | Controller shows "Connection lost" | Press SYNC on it and it reconnects to the same player by itself, or tap Scan. After two minutes it's removed | | Controllers drop when a game starts | Some phones clear background apps when a game launches. Tap the yellow banner at the top to let the app run in the background (or set its battery use to unrestricted), and exclude it from the game launcher's cleanup | diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 229986da..945753f6 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -3,6 +3,7 @@ package com.joegec.joycon2android import android.content.Context import com.joegec.joycon2android.connection.ConnectionPriorityRepository import com.joegec.joycon2android.connection.ControllerRepository +import com.joegec.joycon2android.connection.ControllerTypePreferencesDataStore import com.joegec.joycon2android.connection.DisconnectControllerUseCase import com.joegec.joycon2android.connection.Joycon2Manager import com.joegec.joycon2android.connection.ObserveViewModeUseCase @@ -64,6 +65,7 @@ import com.joegec.joycon2android.emulator.edenGamepads import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase import com.joegec.joycon2android.session.SessionCoordinator +import com.joegec.joycon2android.session.SetControllerTypeUseCase import com.joegec.joycon2android.session.UnassignControllerUseCase import com.joegec.joycon2android.dsu.DisableDsuUseCase import com.joegec.joycon2android.dsu.DsuRepository @@ -120,7 +122,7 @@ class AppContainer(context: Context) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // --- Connection (BLE) --- - private val joycon2Manager = Joycon2Manager(appContext, scope) + private val joycon2Manager = Joycon2Manager(appContext, scope, ControllerTypePreferencesDataStore(appContext)) val controllerRepository: ControllerRepository = joycon2Manager private val connectionPriorityRepository: ConnectionPriorityRepository = joycon2Manager private val setHighConnectionPriority = SetHighConnectionPriorityUseCase(connectionPriorityRepository) @@ -294,6 +296,7 @@ class AppContainer(context: Context) { val observeSession = ObserveSessionUseCase(sessionCoordinator) val assignController = AssignControllerUseCase(sessionCoordinator) val unassignController = UnassignControllerUseCase(sessionCoordinator) + val setControllerType = SetControllerTypeUseCase(sessionCoordinator) fun disconnectAll() { disableGamepad() diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index 3c23db75..42de0ab3 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -348,6 +348,7 @@ class MainActivity : ComponentActivity() { onAssign = viewModel::assignToPlayer, onUnassign = viewModel::unassign, onDisconnect = viewModel::disconnect, + onSetControllerType = viewModel::setControllerType, onGamepadToggle = { enabled -> gamepadViewModel.toggle(enabled, state.activePlayers) }, diff --git a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt index 91903f06..f75a0a3a 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt @@ -14,6 +14,7 @@ import com.joegec.joycon2android.ble.BlePermissionHandler import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.ConnectionViewMode import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.power.BatteryOptimizationExemption import com.joegec.joycon2android.service.Joycon2Service import kotlinx.coroutines.flow.MutableStateFlow @@ -100,6 +101,8 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) fun disconnect(address: String) = container.disconnectController(address) + fun setControllerType(address: String, model: ControllerModel?) = container.setControllerType(address, model) + fun onPermissionsDenied() { _permissionDenied.value = true } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index 7f753f79..659de430 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -73,6 +73,7 @@ import com.joegec.joycon2android.R import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard import com.joegec.joycon2android.assignment.presentation.AssignmentPanel import com.joegec.joycon2android.model.ConnectionViewMode @@ -134,6 +135,7 @@ fun JoyconScreen( onAssign: (String, PlayerNumber) -> Unit, onUnassign: (String) -> Unit, onDisconnect: (String) -> Unit, + onSetControllerType: (String, ControllerModel?) -> Unit, onGamepadToggle: (Boolean) -> Unit, gamepadEmulators: List, selectedGamepadEmulator: String, @@ -255,6 +257,7 @@ fun JoyconScreen( gamepadEmulators, selectedGamepadEmulator, onSelectGamepadEmulator, gamepadSetupPhase, onConfigureGamepad, onOpenGamepadMapping, onScan, onDisconnectAll, onAssign, unassignController, removePlayer, onDisconnect, + onSetControllerType, onGamepadToggle, onDsuToggle, onSelectDsuEmulator, onConfigureDsu, onOpenDsuMapping, ) else -> ScanningContent(state) @@ -463,6 +466,7 @@ private fun ConnectedContent( onUnassign: (String) -> Unit, onRemovePlayer: (PlayerState) -> Unit, onDisconnect: (String) -> Unit, + onSetControllerType: (String, ControllerModel?) -> Unit, onGamepadToggle: (Boolean) -> Unit, onDsuToggle: (Boolean) -> Unit, onSelectDsuEmulator: (String) -> Unit, @@ -479,6 +483,7 @@ private fun ConnectedContent( players = state.players, onAssign = onAssign, onDisconnect = onDisconnect, + onSetType = onSetControllerType, ) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index acbe3dfc..5c012924 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -1,12 +1,14 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.model.AnalogTriggerMapper import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.SidewaysMapper -/** What the relay reports for this button once [SidewaysMapper] has rotated a lone Joy-Con. */ +/** What the relay reports for this button once [SidewaysMapper] or [AnalogTriggerMapper] has moved it. */ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { val emittedId = when (side) { JoyconSide.DUAL -> id + JoyconSide.GAMECUBE -> AnalogTriggerMapper.emittedId(id) ?: return null JoyconSide.LEFT -> SidewaysMapper.remapButtonsLeft(setOf(id)).first() JoyconSide.RIGHT -> SidewaysMapper.remapButtonsRight(setOf(id)).first() } @@ -15,7 +17,7 @@ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { /** A lone Joy-Con's one stick is rotated onto the left-stick axes, whichever side it came from. */ fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource = - if (side == JoyconSide.DUAL) stick else StickSource.LEFT_STICK + if (side.isLone) StickSource.LEFT_STICK else stick /** The relay reports a lone Joy-Con's up as its rail; off a sideways remote, up is its L/R edge. */ fun MappingSource.Stick.emittedDirection(side: JoyconSide, sidewaysRemote: Boolean): StickDirection = when { diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index 84eda56f..7a667fde 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -1,4 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** A lone Joy-Con of one side, or a full controller (pair or Pro). */ -enum class JoyconSide { LEFT, RIGHT, DUAL } +/** A lone Joy-Con of one side, a full controller (pair or Pro), or the NSO GameCube controller. */ +enum class JoyconSide(val isLone: Boolean = false) { LEFT(isLone = true), RIGHT(isLone = true), DUAL, GAMECUBE } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt index b2e5c10e..25006ef1 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt @@ -10,6 +10,7 @@ data class PlayerBody(val player: PlayerNumber, val side: JoyconSide) fun PlayerState.body(): PlayerBody? = joyconSide()?.let { PlayerBody(player, it) } fun PlayerState.joyconSide(): JoyconSide? = when { + hasGameCube -> JoyconSide.GAMECUBE hasPro || hasFullController -> JoyconSide.DUAL left != null -> JoyconSide.LEFT right != null -> JoyconSide.RIGHT diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt index efdbf018..94a3ccc5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt @@ -25,6 +25,7 @@ import com.joegec.joycon2android.model.JoyconButton.SrLeft import com.joegec.joycon2android.model.JoyconButton.SrRight import com.joegec.joycon2android.model.JoyconButton.Up import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.ZR import com.joegec.joycon2android.model.JoyconButton.Y object GameCubeMapping : MappingPreset { @@ -48,6 +49,21 @@ object GameCubeMapping : MappingPreset { GameCubeButton.DPadLeft to Left, GameCubeButton.DPadRight to Right, ) + // Its Z button reports as ZR. + JoyconSide.GAMECUBE -> mapOf( + GameCubeButton.A to A, + GameCubeButton.B to B, + GameCubeButton.X to X, + GameCubeButton.Y to Y, + GameCubeButton.Z to ZR, + GameCubeButton.Start to Plus, + GameCubeButton.TriggerL to L, + GameCubeButton.TriggerR to R, + GameCubeButton.DPadUp to Up, + GameCubeButton.DPadDown to Down, + GameCubeButton.DPadLeft to Left, + GameCubeButton.DPadRight to Right, + ) JoyconSide.LEFT -> mapOf( GameCubeButton.A to Down, GameCubeButton.B to Left, @@ -71,7 +87,7 @@ object GameCubeMapping : MappingPreset { } private fun sticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( GameCubeStick.MainStick to LEFT_STICK, GameCubeStick.CStick to RIGHT_STICK, ) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index 37e6a86d..173262cd 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -13,6 +13,8 @@ import com.joegec.joycon2android.model.JoyconButton.R import com.joegec.joycon2android.model.JoyconButton.RS import com.joegec.joycon2android.model.JoyconButton.Right import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y import com.joegec.joycon2android.model.JoyconButton.ZL import com.joegec.joycon2android.model.JoyconButton.ZR @@ -31,6 +33,14 @@ object JoyconWiiMapping : MappingPreset { WiimoteButton.Minus to Minus, WiimoteButton.Recenter to RS, ) + // No − and no stick click: − takes X as on a lone right Joy-Con, and Recenter the free Y. + JoyconSide.GAMECUBE -> mapOf( + WiimoteButton.B to B, + WiimoteButton.One to R, + WiimoteButton.Two to ZR, + WiimoteButton.Minus to X, + WiimoteButton.Recenter to Y, + ) JoyconSide.RIGHT -> mapOf( WiimoteButton.B to B, WiimoteButton.One to R, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt index 64174ad2..241308d0 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -41,7 +41,7 @@ object MarioKartNunchukMapping : MappingPreset { override val sidewaysRemote = true override fun entries(side: JoyconSide) = when (side) { - JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons() + JoyconSide.DUAL, JoyconSide.GAMECUBE -> WiiMapping.entries(side) + pairButtons() else -> loneButtons(side) + nunchukStick(side).stickEntries() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt index 4a6c07f2..6b5573e6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -37,27 +37,30 @@ object SwitchProMapping : MappingPreset { override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() + private val PAIR_BUTTONS = mapOf( + SwitchProButton.A to A, + SwitchProButton.B to B, + SwitchProButton.X to X, + SwitchProButton.Y to Y, + SwitchProButton.L to L, + SwitchProButton.R to R, + SwitchProButton.ZL to ZL, + SwitchProButton.ZR to ZR, + SwitchProButton.Plus to Plus, + SwitchProButton.Minus to Minus, + SwitchProButton.Home to Home, + SwitchProButton.Capture to Capture, + SwitchProButton.LStickClick to LS, + SwitchProButton.RStickClick to RS, + SwitchProButton.DPadUp to Up, + SwitchProButton.DPadDown to Down, + SwitchProButton.DPadLeft to Left, + SwitchProButton.DPadRight to Right, + ) + private fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - SwitchProButton.A to A, - SwitchProButton.B to B, - SwitchProButton.X to X, - SwitchProButton.Y to Y, - SwitchProButton.L to L, - SwitchProButton.R to R, - SwitchProButton.ZL to ZL, - SwitchProButton.ZR to ZR, - SwitchProButton.Plus to Plus, - SwitchProButton.Minus to Minus, - SwitchProButton.Home to Home, - SwitchProButton.Capture to Capture, - SwitchProButton.LStickClick to LS, - SwitchProButton.RStickClick to RS, - SwitchProButton.DPadUp to Up, - SwitchProButton.DPadDown to Down, - SwitchProButton.DPadLeft to Left, - SwitchProButton.DPadRight to Right, - ) + JoyconSide.DUAL -> PAIR_BUTTONS + JoyconSide.GAMECUBE -> PAIR_BUTTONS - SwitchProButton.LStickClick - SwitchProButton.RStickClick // Rails as shoulders, ZL/ZR unbound: docs/virtual-gamepad.md#sidewaysmapper JoyconSide.LEFT -> mapOf( SwitchProButton.A to Down, @@ -84,7 +87,7 @@ object SwitchProMapping : MappingPreset { } private fun sticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( SwitchProStick.LStick to LEFT_STICK, SwitchProStick.RStick to RIGHT_STICK, ) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index b3adf1e2..b93da115 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -36,7 +36,7 @@ object WiiMapping : MappingPreset { buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + nunchukStick(side).stickEntries() internal fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( WiimoteButton.A to A, WiimoteButton.B to ZR, WiimoteButton.One to Y, @@ -77,7 +77,7 @@ object WiiMapping : MappingPreset { // Sideways, the cluster is the face buttons, so the stick drives the d-pad. internal fun dPadSticks(side: JoyconSide): Map> { val stick = when (side) { - JoyconSide.DUAL -> return emptyMap() + JoyconSide.DUAL, JoyconSide.GAMECUBE -> return emptyMap() JoyconSide.LEFT -> LEFT_STICK JoyconSide.RIGHT -> RIGHT_STICK } @@ -90,7 +90,7 @@ object WiiMapping : MappingPreset { } internal fun nunchukStick(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) else -> emptyMap() } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt new file mode 100644 index 00000000..93dbc4d5 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlayerBodyTest { + + private fun controller(model: ControllerModel) = + ConnectedJoycon(address = model.name, side = Side.PRO, model = model, deviceName = "Joy-Con 2") + + @Test + fun `a gamecube controller maps as its own body, a pro controller as a pair`() { + val gameCube = controller(ControllerModel.GAMECUBE) + val pro = controller(ControllerModel.PRO_CONTROLLER) + + assertEquals(JoyconSide.GAMECUBE, PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube).joyconSide()) + assertEquals(JoyconSide.DUAL, PlayerState(PlayerNumber.P1, left = pro, right = pro).joyconSide()) + } + + @Test + fun `a gamecube controller's ZL and Z are reported as the bumpers and its trigger clicks as the stick clicks`() { + assertEquals(JoyconButton.R, JoyconButton.ZR.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.L, JoyconButton.ZL.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.RS, JoyconButton.R.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.LS, JoyconButton.L.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.ZR, JoyconButton.ZR.emittedFor(JoyconSide.DUAL)) + } + + @Test + fun `a gamecube controller's stick clicks emit nothing, so they can't fire with a trigger click`() { + assertEquals(null, JoyconButton.LS.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(null, JoyconButton.RS.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.LS, JoyconButton.LS.emittedFor(JoyconSide.DUAL)) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 0580dc07..18f47771 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.MappingLayouts import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.buttonmapping.toMappingSources import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import org.junit.Assert.assertEquals @@ -54,12 +55,19 @@ class WiiPresetsTest { assertEquals("LS", left.getValue(WiimoteButton.Recenter.name)) } + @Test + fun `the Joy-Con layout puts a gamecube controller's missing minus and stick click on X and Y`() { + val gameCube = JoyconWiiMapping.entries(JoyconSide.GAMECUBE) + assertEquals("X", gameCube.getValue(WiimoteButton.Minus.name)) + assertEquals("Y", gameCube.getValue(WiimoteButton.Recenter.name)) + } + @Test fun `no button fires two of the Joy-Con layout's targets`() { JoyconSide.entries.forEach { side -> val fired = mutableMapOf>() JoyconWiiMapping.entries(side).forEach { (target, value) -> - sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + emittedIds(value, side).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } } assertEquals("$side", emptyMap>(), fired.filterValues { it.size > 1 }) @@ -97,7 +105,7 @@ class WiiPresetsTest { @Test fun `a lone Joy-Con on the Nunchuck layout plays both halves itself`() { - JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + JoyconSide.entries.filter { it.isLone }.forEach { side -> val lone = MarioKartNunchukMapping.entries(side) val rail = if (side == JoyconSide.LEFT) "SlLeft" else "SlRight" @@ -125,6 +133,10 @@ class WiiPresetsTest { } } + private fun emittedIds(value: String, side: JoyconSide) = value.toMappingSources().mapNotNull { source -> + if (source is MappingSource.Button) source.button.emittedFor(side)?.name else source.id + } + private fun emittedFace(entries: Map, target: WiimoteButton, side: JoyconSide) = (MappingSource.fromId(sourceIdsOf(entries.getValue(target.name)).first()) as? MappingSource.Button) ?.button @@ -132,7 +144,7 @@ class WiiPresetsTest { @Test fun `no button on a lone Joy-Con fires two targets, bar the shoulder that hops and tricks`() { - JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + JoyconSide.entries.filter { it.isLone }.forEach { side -> val entries = MappingLayouts.entriesOf(Console.WIIMOTE_NUNCHUK, side, MarioKartNunchukMapping) val fired = mutableMapOf>() entries.forEach { (target, value) -> @@ -188,7 +200,7 @@ class WiiPresetsTest { @Test fun `Mario Kart Wheel tricks off SR on a lone Joy-Con, the shoulder that already hops`() { - JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + JoyconSide.entries.filter { it.isLone }.forEach { side -> val lone = MarioKartWheelMapping.entries(side) assertEquals("$side", lone.getValue(WiimoteButton.B.name), lone.getValue(WiimoteButton.Shake.name)) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt new file mode 100644 index 00000000..14dcdf2f --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.controller_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.controller_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.controller_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.controller_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt index 6e2c490a..1486e362 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -29,6 +29,7 @@ internal fun JoyconSide.shortLabel(): String = when (this) { JoyconSide.LEFT -> stringResource(R.string.side_left_short) JoyconSide.RIGHT -> stringResource(R.string.side_right_short) JoyconSide.DUAL -> stringResource(R.string.side_dual_short) + JoyconSide.GAMECUBE -> stringResource(R.string.side_gamecube_short) } @Composable diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 7608485e..670fcb23 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -17,13 +17,14 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.GameCubeButtons import com.joegec.joycon2android.model.JoyconButton internal object MappingOptions { const val NONE_ID = "" fun offersSidewaysRemote(console: Console, side: JoyconSide) = - console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL + console == Console.WIIMOTE_NUNCHUK && side.isLone fun offersRemoteMotion(console: Console) = console == Console.WIIMOTE_NUNCHUK @@ -67,20 +68,20 @@ internal object MappingOptions { @Composable fun sources(side: JoyconSide, section: MappingSection): List> = listOf(NONE_ID to stringResource(R.string.source_none)) + - physicalButtons(side).map { it.name to it.id } + + physicalButtons(side).map { it.name to it.markingOn(side) } + if (section == MappingSection.ANDROID) emptyList() else stickDirections(side) // A lone Joy-Con has one stick, so its directions need no "Left"/"Right" to tell them apart. @Composable private fun stickDirections(side: JoyconSide): List> { val sticks = when (side) { - JoyconSide.DUAL -> StickSource.entries + JoyconSide.DUAL, JoyconSide.GAMECUBE -> StickSource.entries JoyconSide.LEFT -> listOf(StickSource.LEFT_STICK) JoyconSide.RIGHT -> listOf(StickSource.RIGHT_STICK) } return sticks.flatMap(MappingSource::directionsOf).map { source -> val stick = - if (side == JoyconSide.DUAL) source.stick.label() else stringResource(R.string.stick_lone) + if (side.isLone) stringResource(R.string.stick_lone) else source.stick.label() source.id to stringResource(R.string.source_direction, stick, source.direction.label()) } } @@ -88,6 +89,7 @@ internal object MappingOptions { // Only what that side physically has, so every choice can fire. private fun physicalButtons(side: JoyconSide): List = when (side) { JoyconSide.DUAL -> JoyconButton.entries + JoyconSide.GAMECUBE -> GameCubeButtons.present.toList() JoyconSide.LEFT -> listOf( JoyconButton.L, JoyconButton.ZL, JoyconButton.Minus, JoyconButton.LS, JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, @@ -99,4 +101,7 @@ internal object MappingOptions { JoyconButton.Home, JoyconButton.Chat, JoyconButton.SrRight, JoyconButton.SlRight, ) } + + private fun JoyconButton.markingOn(side: JoyconSide): String = + (if (side == JoyconSide.GAMECUBE) GameCubeButtons.marking(this) else null) ?: id } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index 5945c835..9d034343 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -149,18 +149,18 @@ private fun CardHeader(player: PlayerState, layoutName: String?, expanded: Boole private fun ControllerChips(player: PlayerState) { Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { if (player.hasPro) { - ControllerChip(R.string.controller_pro, player.left!!) + ControllerChip(player.left!!.modelLabel(), player.left!!) } else { - player.left?.let { ControllerChip(R.string.controller_left, it) } - player.right?.let { ControllerChip(R.string.controller_right, it) } + player.left?.let { ControllerChip(stringResource(R.string.controller_left), it) } + player.right?.let { ControllerChip(stringResource(R.string.controller_right), it) } } } } @Composable -private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { +private fun ControllerChip(text: String, joycon: ConnectedJoycon) { Text( - stringResource(textRes), + text, color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), style = MaterialTheme.typography.titleMedium, ) diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 2b56f6d2..a049a7d2 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Left Right Pro + GameCube Standard Wii Like a Wiimote, ZR \u2192 B @@ -41,6 +42,7 @@ L R L/R + GC P%1$d %2$s Up Down diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt new file mode 100644 index 00000000..787c72d4 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt @@ -0,0 +1,19 @@ +package com.joegec.joycon2android.model + +/** Where each shoulder goes, and why: docs/virtual-gamepad.md#analog-triggers */ +object AnalogTriggerMapper { + + fun remapButtons(pressed: Set): Set = + pressed.mapTo(mutableSetOf()) { REMAP[it] ?: it } + + /** Null for a stick click, whose bits carry the trigger clicks instead. */ + fun emittedId(id: String): String? = REMAP[id] ?: id.takeUnless { it in REMAP.values } + + // A controller with analog triggers has no stick clicks to lose. + private val REMAP = mapOf( + JoyconButton.ZL.id to JoyconButton.L.id, + JoyconButton.ZR.id to JoyconButton.R.id, + JoyconButton.L.id to JoyconButton.LS.id, + JoyconButton.R.id to JoyconButton.RS.id, + ) +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index a9d6d957..7cfa42f5 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -4,6 +4,9 @@ data class ConnectedJoycon( val address: String, val side: Side, val deviceName: String, + val model: ControllerModel = ControllerModel.UNKNOWN, + val detectedModel: ControllerModel = model, + val typeOverride: ControllerModel? = null, val connectionState: JoyconConnectionState = JoyconConnectionState(), val input: JoyconInput = JoyconInput(), val assignedPlayer: PlayerNumber? = null, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt new file mode 100644 index 00000000..129080f8 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.model + +/** From the advertised product ID: docs/protocol.md#advertising */ +enum class ControllerModel(val defaultSide: Side, val hasAnalogTriggers: Boolean = false) { + JOYCON_LEFT(Side.LEFT), + JOYCON_RIGHT(Side.RIGHT), + PRO_CONTROLLER(Side.PRO), + GAMECUBE(Side.PRO, hasAnalogTriggers = true), + UNKNOWN(Side.UNKNOWN), +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt new file mode 100644 index 00000000..6e98440d --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.model + +// The NSO GameCube controller has no −, stick clicks or back paddles. +object GameCubeButtons { + val present: Set = setOf( + JoyconButton.A, JoyconButton.B, JoyconButton.X, JoyconButton.Y, + JoyconButton.L, JoyconButton.R, JoyconButton.ZL, JoyconButton.ZR, JoyconButton.Plus, + JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, + JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, + ) + + /** Null where the controller marks the button as the Switch 2 controllers do. Its Z reports as ZR. */ + fun marking(button: JoyconButton): String? = if (button == JoyconButton.ZR) "Z" else null +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt index 57adf1bf..06f1a6de 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt @@ -6,11 +6,29 @@ data class GamepadState( val leftStickY: Int, val rightStickX: Int, val rightStickY: Int, + val leftTrigger: Int = 0, + val rightTrigger: Int = 0, ) { companion object { private const val CENTER = 2048 + private const val TRIGGER_PULLED = 255 - fun from(state: PlayerState): GamepadState = when { + fun from(state: PlayerState): GamepadState = buttonsAndSticks(state).withTriggers(state) + + private fun GamepadState.withTriggers(state: PlayerState): GamepadState = + if (state.hasAnalogTriggers) { + copy( + pressed = AnalogTriggerMapper.remapButtons(pressed), + leftTrigger = state.leftInput.triggerLeft, + rightTrigger = state.leftInput.triggerRight, + ) + } else { + copy(leftTrigger = pulledBy(JoyconButton.ZL), rightTrigger = pulledBy(JoyconButton.ZR)) + } + + private fun GamepadState.pulledBy(button: JoyconButton): Int = if (button.id in pressed) TRIGGER_PULLED else 0 + + private fun buttonsAndSticks(state: PlayerState): GamepadState = when { state.hasFullController -> GamepadState( pressed = state.pressed, leftStickX = state.leftStickX, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt index 4d20036e..7845600c 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt @@ -16,4 +16,6 @@ data class JoyconInput( val gyroZ: Int = 0, val battery: BatteryCharge? = null, val motionSupport: MotionSupport = MotionSupport.Full, + val triggerLeft: Int = 0, + val triggerRight: Int = 0, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt index 223a3222..f83ed217 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt @@ -9,6 +9,8 @@ data class PlayerState( val hasController: Boolean get() = left != null || right != null val hasFullController: Boolean get() = left != null && right != null val isSideways: Boolean get() = hasController && !hasFullController && !hasPro + val hasAnalogTriggers: Boolean get() = hasPro && left!!.model.hasAnalogTriggers + val hasGameCube: Boolean get() = hasPro && left!!.model == ControllerModel.GAMECUBE // As the hardware reports it, before any sideways rotation ([gamepad]). val pressed: Set diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt new file mode 100644 index 00000000..f06607c8 --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt @@ -0,0 +1,22 @@ +package com.joegec.joycon2android.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class GameCubeButtonsTest { + + @Test + fun `a gamecube controller has no minus, stick clicks or back paddles`() { + listOf(JoyconButton.Minus, JoyconButton.LS, JoyconButton.RS, JoyconButton.GL, JoyconButton.GR).forEach { + assertFalse("$it", it in GameCubeButtons.present) + } + } + + @Test + fun `its ZR is marked Z and every other button keeps its marking`() { + assertEquals("Z", GameCubeButtons.marking(JoyconButton.ZR)) + assertNull(GameCubeButtons.marking(JoyconButton.ZL)) + } +} diff --git a/core/session/build.gradle.kts b/core/session/build.gradle.kts index fe0f16a6..6c8a5e5e 100644 --- a/core/session/build.gradle.kts +++ b/core/session/build.gradle.kts @@ -8,4 +8,5 @@ dependencies { api(project(":feature:connection:domain")) api(project(":feature:assignment:domain")) api(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) } diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt index afd65fdc..dc9a7f33 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -60,6 +61,12 @@ class SessionCoordinator( player?.let(onPlayerUnassigned) } + // Unassigned first: the assignment's slot checks were made for the old type. + fun setControllerType(address: String, model: ControllerModel?) { + if (assignments.getPlayer(address) != null) unassign(address) + controllers.setControllerType(address, model) + } + private fun publish(state: AppUiState) { _uiState.value = state onState(state) diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt new file mode 100644 index 00000000..b291621e --- /dev/null +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.session + +import com.joegec.joycon2android.model.ControllerModel + +class SetControllerTypeUseCase(private val coordinator: SessionCoordinator) { + operator fun invoke(address: String, model: ControllerModel?) = coordinator.setControllerType(address, model) +} diff --git a/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt new file mode 100644 index 00000000..04f92c27 --- /dev/null +++ b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt @@ -0,0 +1,86 @@ +package com.joegec.joycon2android.session + +import com.joegec.joycon2android.assignment.AssignmentRepository +import com.joegec.joycon2android.assignment.ComboAssignmentDetector +import com.joegec.joycon2android.assignment.PlayerStateResolver +import com.joegec.joycon2android.connection.ControllerRepository +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.Side +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionCoordinatorTypeTest { + + private val controllers = FakeControllers() + private val assignments = FakeAssignments() + private val unassignedPlayers = mutableListOf() + private val coordinator = SessionCoordinator( + scope = CoroutineScope(Dispatchers.Unconfined), + controllers = controllers, + assignments = assignments, + resolver = PlayerStateResolver(evictConflicting = {}), + comboDetector = ComboAssignmentDetector(), + onState = {}, + onPlayerAssigned = {}, + onPlayerUnassigned = { unassignedPlayers += it }, + ) + + @Test + fun `an assigned controller leaves its player before its type changes`() { + assignments.players[ADDRESS] = PlayerNumber.P2 + + coordinator.setControllerType(ADDRESS, ControllerModel.GAMECUBE) + + assertNull(assignments.players[ADDRESS]) + assertEquals(listOf(PlayerNumber.P2), unassignedPlayers) + assertEquals(ADDRESS to ControllerModel.GAMECUBE, controllers.typeChanges.single()) + } + + @Test + fun `an unassigned controller just changes type`() { + coordinator.setControllerType(ADDRESS, null) + + assertEquals(emptyList(), unassignedPlayers) + assertEquals(ADDRESS to null, controllers.typeChanges.single()) + } + + private class FakeControllers : ControllerRepository { + val typeChanges = mutableListOf>() + override val controllers: StateFlow> = MutableStateFlow(emptyList()) + override val scanning: StateFlow = MutableStateFlow(false) + override val error: StateFlow = MutableStateFlow(null) + override fun startScan() = Unit + override fun stopScan() = Unit + override fun disconnect(address: String) = Unit + override fun disconnectAll() = Unit + override fun setPlayerLed(address: String, player: PlayerNumber?) = Unit + override fun setControllerType(address: String, model: ControllerModel?) { + typeChanges += address to model + } + override fun emitError(message: String) = Unit + } + + private class FakeAssignments : AssignmentRepository { + val players = mutableMapOf() + override val assignments: StateFlow> = MutableStateFlow(emptyMap()) + override fun assign(address: String, side: Side, player: PlayerNumber): Boolean = players.put(address, player) == null + override fun unassign(address: String) { + players.remove(address) + } + override fun unassignAll() = players.clear() + override fun getPlayer(address: String): PlayerNumber? = players[address] + override fun nextFreePlayer(): PlayerNumber? = null + override fun addressesForPlayer(player: PlayerNumber): List = players.filterValues { it == player }.keys.toList() + } + + private companion object { + const val ADDRESS = "3C:A9:AB:5E:6A:A6" + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a1eb060d..5889e16f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -102,6 +102,13 @@ In `JoyconScreen.kt`: inset as top clearance. - **Settings behind a gear.** The app bar's only action opens the settings panel, an `EndDrawer` from the right so it sits under the icon that opened it. The Ko-fi banner sits under the panel title. +- **The type label is the type menu.** An unassigned controller's type at the top of its card opens + a `PanelDropdownMenu`: Detect automatically first, sub-labelled with what was detected, then the + four types. A manual choice shows `SET MANUALLY` beside the label. Only an unrecognised controller + gets a `WarningBox`, which opens the same menu; a recognised one costs no extra row. +- **A GameCube controller is named and drawn as itself.** Its card, compact row and mapping card + say GameCube, and its detailed view is the Pro layout without the buttons it lacks, with ZR marked + Z and its triggers, L and R, above ZL and Z. `GameCubeButtons` is the one list of what it has, which the mapping editor offers too. ### Landscape diff --git a/docs/architecture.md b/docs/architecture.md index a02ccd55..29ff90b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,8 @@ shipped layouts in `preset/`), persistence (`data`) and the editor (`presentatio ## Button mapping -- **Keyed by `PlayerBody`** — a player plus the body they hold — so each player maps independently. +- **Keyed by `PlayerBody`** — a player plus the body they hold (a pair or Pro, a lone Joy-Con, or a + GameCube controller) — so each player maps independently. - **A target holds every source bound to it.** Dolphin ORs them into one expression; Eden binds one input per key, so it keeps the first the body can emit. - **A layout is a name for a set of bindings, never a stored reference.** Applying one copies out diff --git a/docs/protocol.md b/docs/protocol.md index ab060ca1..10dc558f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -24,9 +24,10 @@ enables a subscription that never delivers data. Manufacturer data for ID `0x0553` carries: - **Bytes `[5..6]`** — little-endian product ID: `0x2067` left Joy-Con 2, `0x2066` right Joy-Con 2, - `0x2069` Switch 2 Pro Controller. The advertisement has no local name, so this is the only type - signal before input starts. Left and right are confirmed on hardware; the Pro value is community - reverse-engineering. + `0x2069` Switch 2 Pro Controller, `0x2073` NSO GameCube controller. The advertisement has no local + name, so this is the only type signal before input starts. Left, right and GameCube are confirmed + on hardware; the Pro value is community reverse-engineering. `JoyconAdvertisement.model` maps it to + a `ControllerModel`; the GameCube controller is held and decoded as a Pro Controller. - **Bytes `[10..15]`** — the bonded host's MAC. Holding SYNC zeroes it; a button press on a synced controller wakes it into a short reconnect advertisement carrying the address. The scanner only accepts a zeroed field, so stray presses on nearby synced Joy-Cons don't flash into the list. @@ -114,8 +115,8 @@ stream. | Temperature | 0x2E | int16 | °C = 25 + raw / 127 | | Accel X/Y/Z | 0x30–0x35 | int16 ×3 | 4096 = 1 g | | Gyro X/Y/Z | 0x36–0x3B | int16 ×3 | 48000 = 360 °/s | -| Trigger L | 0x3C | uint8 | analog | -| Trigger R | 0x3D | uint8 | analog | +| Trigger L | 0x3C | uint8 | analog, GameCube controller only ([below](#analog-triggers)) | +| Trigger R | 0x3D | uint8 | analog, GameCube controller only | A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick bytes are too. @@ -147,10 +148,49 @@ A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick The mask's low nibble lights P1–P4 solid (`0x01`, `0x02`, `0x04`, `0x08`), its high nibble flashes them (`0x10` … `0x80`). `0xF0`, all flashing, is the controller's default cycling animation. +### Analog triggers + +The NSO GameCube controller's L and R travel to a **first stop**, where analog travel ends, then +click through to a **second stop**, which sets the L/R button bit. Measured on three units with a +Retroid Pocket Nova, 2026-10-07: + +| | Raw | +|---|---| +| Factory zero (SPI `0x013140`, byte 0 left, byte 1 right; `0xFF` unset) | 31–35 | +| Rest | factory zero −2..+4 | +| First stop | 170–196, differing by side and unit | +| Second stop, bit set | 209–242 | + +- **The click is the bit, never an analog threshold.** In quick presses the bit can show with the + analog value at 155, and values up to 220 pass without it. +- `TriggerCalibrator` maps factory zero plus a 5-count dead zone to 0 and the first stop to 255. + Full starts at 170 and widens to a value **held** still for 5 packets without the click, up to 205, + so quick presses through to the click don't move it. Without a factory zero, the lowest value seen + stands in. +- Other drivers (Linux, SDL, BlueRetro) scale to ~225–232, the click point, which caps an unclicked + trigger at ~80%. + ## SPI reads -The controller keeps its factory data in SPI flash. The app wants one field: the **shell accent -colour**, 3 bytes RGB at `0x01301F` — the per-side colour (coral right, blue left) the UI paints each +The controller keeps its factory data in SPI flash. `FactoryCalibrationReads` picks what a model +needs at connect: + +| Field | Address | Length | Read for | +|---|---|---|---| +| Main stick calibration | `0x0130A8` | 9 | the GameCube controller (its main stick) | +| Right stick calibration | `0x0130E8` | 9 | the GameCube controller (its C-stick) | +| [Trigger zeros](#analog-triggers) | `0x013140` | 2 | the GameCube controller | +| Shell accent colour | `0x01301F` | 3 | every model, inside the DeviceInfo read below | + +**Stick calibration** is three X/Y pairs, each packed in 3 bytes as two 12-bit values (X = `b0 | +(b1 & 0x0F) << 8`, Y = `b1 >> 4 | b2 << 4`): the centre, the span above it, the span below it. All +`FF` means unset. Layout and addresses are from the Linux `hid-nintendo` Switch 2 driver (v16) and +SDL's `SDL_hidapi_switch2.c`, both USB; the same flash serves BLE. A user calibration made on a +Switch 2 lives at `0x1FC040` (magic `B2 A1`, then the same 9 bytes); the app doesn't read it, and +sources disagree on the right stick's address. Only the GameCube controller's values have been +checked against its sticks, so Joy-Con 2 and Pro Controllers aren't read and learn theirs instead. + +The **shell accent colour** is the per-side colour (coral right, blue left) the UI paints each controller with. Not the body colour at `0x013019`: that is the near-black shell, the same on both Joy-Cons. @@ -202,11 +242,18 @@ Treating 2048 as centre and half-span leaves full deflection at ~60% with a 4– `StickCalibrator` runs where packets are parsed, so the live display, gamepad and DSU all see corrected values: +- **A GameCube controller's factory calibration wins** once its [flash read](#spi-reads) answers, + about a second after connecting: it sets the centre and each direction's span. Everything below is + how every other controller calibrates, and the GameCube controller's fallback for unset flash and + the first packets before the reply. - **Centre** is learned from the first still window (30 samples), then frozen: a stick held at full deflection is perfectly still too. - **Each direction scales by its own span**, the centre/below/above triple the factory calibration stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works - from the first packet, and only ever widen. + from the first packet, and only ever widen, factory spans included. The NSO GameCube controller's + C-stick travels less, 1022–1204 from centre on three units (2026-10-07), so its spans are seeded + at 1000. +- Changing a controller's type resets the calibrator; the factory values are applied again. ## Console-protocol controllers @@ -304,7 +351,8 @@ Joy-Con 2 never does. Android pairs one device at a time, so a second clone conn first one's pairing is pending sends none, and silence on the common channel 1.5 s after init switches it over instead. Genuine Joy-Con 2s answer the console channel too, so a controller moved over by that timeout returns to the common path on its first report on `...fd2`, which only a -controller speaking that protocol sends. +controller speaking that protocol sends. A controller with analog triggers (the GameCube controller) +never switches: the console report has no trigger bytes, and it answers on the common channel. **Holding the link.** The pairing never succeeds (Confirm Value Failed, or a 30 s timeout): diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index c99ab409..072e069c 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -30,8 +30,8 @@ Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the | 0–1 | 15 button bits, then one padding bit | | 2 | hat switch (low nibble: 0 = N … 7 = NW, `0xF` = centre), padding | | 3–10 | left X, left Y, right X, right Y — int16, −32767..32767, Y inverted (up is negative) | -| 11 | left trigger / brake — digital, 0 or 255 | -| 12 | right trigger / accelerator — digital, 0 or 255 | +| 11 | left trigger / brake — ZL as 0 or 255, or analog L travel ([below](#analog-triggers)) | +| 12 | right trigger / accelerator — ZR as 0 or 255, or analog R travel | | 13 | overflow buttons, in a trailing vendor-defined collection: bit 0 GR, bit 1 C | | 14 | Android keys, in a trailing Consumer Control collection: bit 0 Home, bit 1 Back ([below](#android-keys)) | @@ -54,15 +54,18 @@ shift: | R | 7 | 103 `BUTTON_R1` | | | | - **Capture and GL** take `BUTTON_C` / `BUTTON_Z`, the two slots with no Switch equivalent. +- **A controller with analog triggers moves its shoulders:** ZL/ZR onto L1/R1, the trigger clicks onto + the stick clicks ([below](#analog-triggers)). - **GR and C overflow.** One gamepad collection carries 15 buttons — a 16th lands on `0x13F`, which no key layout names — and the Switch 2 controllers have 17. For a Button usage outside a pointer/joystick/gamepad collection Linux falls back to `BTN_MISC + n - 1`, which key layouts name `BUTTON_1..16`: GR is **188**, C is **189**. The collection is vendor-defined so nothing interprets it. Firmware that re-publishes pads (see [Device identity](#device-identity)) forwards only keys it knows, so it may drop these two. -- **Triggers are Brake (left) and Accelerator (right)**, never reversed. Android aliases - `AXIS_LTRIGGER` to `AXIS_BRAKE` and `AXIS_RTRIGGER` to `AXIS_GAS`, and re-publishing firmware - synthesises `L2`/`R2` from those axes — reversed, a ZR pull arrives as L2. +- **Triggers are Brake (left) and Accelerator (right)**, never reversed. On Android 14+ these arrive + as `AXIS_LTRIGGER`/`AXIS_RTRIGGER` with compat copies as `AXIS_BRAKE` (23) and `AXIS_GAS` (22); + Android 7–13 report only 23 and 22. Re-publishing firmware synthesises `L2`/`R2` from those axes — + reversed, a ZR pull arrives as L2. - **D-pad** is the hat (`AXIS_HAT_X` 15, `AXIS_HAT_Y` 16). Sticks are axes 0/1 (left) and 11/14 (right). Eden's config uses numeric keycodes; Dolphin's uses names (`Button L2` = ZL, `Select` = −, …). @@ -124,6 +127,36 @@ Left Joy-Con turned 90° counter-clockwise, right 90° clockwise, as on a Switch Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sideways-joy-cons). +### Analog triggers + +A controller with analog triggers (`ControllerModel.hasAnalogTriggers`, the NSO GameCube controller) +drives bytes 11/12 with its calibrated L/R travel instead of ZL/ZR +([calibration](protocol.md#analog-triggers)). `AnalogTriggerMapper` moves its other shoulder buttons +so each lands where Android expects that control: + +| Button | Bits | Keycodes | +|---|---|---| +| ZL / Z (the ZR bit) | 6 / 7 | `BUTTON_L1` / `BUTTON_R1` | +| L / R click at the bottom | 13 / 14 | `BUTTON_THUMBL` / `BUTTON_THUMBR` | + +Confirmed in Dolphin on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, +2026-10-13. Eden is untested on hardware. + +- **Nothing goes on `BUTTON_L2`/`BUTTON_R2`:** they can follow the trigger axes. Retroid Pocket Nova, + Android 13, 2026-10-08: with Z on R2, pulling R fired Dolphin's `Button R2` and Z fired nothing. + The Nova's L2/R2 setting wasn't the cause. +- **The stick-click bits are free to reuse:** the controller has no stick clicks. So no binding of + its can use LS/RS: `emittedFor` resolves them to nothing for it, and its Switch Pro layout leaves + the stick-click targets unbound. +- **DSU carries no trigger travel.** Its packet has analog L2/R2 bytes, but `DsuPacketEncoder` fills + each analog byte from its button, 0 or 255, and this controller's ZL/ZR bits are moved, so L2/R2 + stay 0. Its buttons reach DSU as they reach the pad: ZL/Z as L1/R1, the clicks as L3/R3. + +For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and +``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` +half. Those two ids exist on every Android version the app supports. `Triggers/L`/`R` stay on the +click: while it is held, Dolphin reports the analog value as full. + ## Emulator config - **Dolphin's names** for each Android keycode and hat direction (`DolphinGcpadConfig`) are diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 8eaa8412..59666078 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -26,6 +26,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -33,9 +37,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.joegec.joycon2android.assignment.presentation.R import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.ui.components.WarningBox import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens @@ -51,6 +57,7 @@ fun AssignmentPanel( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, + onSetType: (String, ControllerModel?) -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(Dimens.cardCorner) @@ -71,7 +78,7 @@ fun AssignmentPanel( ) unassigned.forEach { joycon -> - JoyconAssignmentRow(joycon, players, onAssign, onDisconnect) + JoyconAssignmentRow(joycon, players, onAssign, onDisconnect, onSetType) } Text( @@ -89,14 +96,12 @@ private fun JoyconAssignmentRow( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, + onSetType: (String, ControllerModel?) -> Unit, ) { val sideColor = joyconBorderColor(joycon.accentColor, JoyconDefaultColor) - val sideLabel = when (joycon.side) { - Side.LEFT -> stringResource(R.string.side_left) - Side.RIGHT -> stringResource(R.string.side_right) - Side.PRO -> stringResource(R.string.side_pro) - Side.UNKNOWN -> joycon.deviceName - } + val sideLabel = joycon.modelLabel() + + var typeMenuOpen by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( @@ -108,11 +113,21 @@ private fun JoyconAssignmentRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - sideLabel, + ControllerTypeMenu( + joycon, + label = sideLabel, color = sideColor, - style = MaterialTheme.typography.titleSmall, + expanded = typeMenuOpen, + onExpandedChange = { typeMenuOpen = it }, + onSetType = { model -> onSetType(joycon.address, model) }, ) + if (joycon.typeOverride != null) { + Text( + stringResource(R.string.type_manual), + color = TextDim, + style = MaterialTheme.typography.bodySmall, + ) + } AnimatedVisibility( visible = joycon.connectionLost, enter = fadeIn() + expandHorizontally(), @@ -158,6 +173,13 @@ private fun JoyconAssignmentRow( ) } } + if (joycon.model == ControllerModel.UNKNOWN) { + WarningBox( + stringResource(R.string.type_unrecognised), + modifier = Modifier.padding(horizontal = Dimens.cardPadding), + onClick = { typeMenuOpen = true }, + ) + } PlayerSelection(joycon, players, onAssign) } } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt new file mode 100644 index 00000000..b64bf236 --- /dev/null +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt @@ -0,0 +1,24 @@ +package com.joegec.joycon2android.assignment.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel + +@Composable +internal fun ControllerModel.label(): String = when (this) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.type_joycon_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.type_joycon_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.type_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.type_gamecube) + ControllerModel.UNKNOWN -> stringResource(R.string.type_unknown) +} + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.side_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.side_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.side_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.side_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt new file mode 100644 index 00000000..69f0fc22 --- /dev/null +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt @@ -0,0 +1,75 @@ +package com.joegec.joycon2android.assignment.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.PanelDropdownMenu +import com.joegec.joycon2android.ui.theme.Dimens + +private const val DETECT_ID = "DETECT" +private val choosableModels = ControllerModel.entries - ControllerModel.UNKNOWN + +@Composable +internal fun ControllerTypeMenu( + joycon: ConnectedJoycon, + label: String, + color: Color, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSetType: (ControllerModel?) -> Unit, +) { + Box { + Row( + Modifier + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable(onClickLabel = stringResource(R.string.type_change)) { onExpandedChange(true) } + .heightIn(min = Dimens.minTouchTarget), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = color, style = MaterialTheme.typography.titleSmall) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = color, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + options = typeOptions(joycon), + selectedId = joycon.typeOverride?.name ?: DETECT_ID, + onSelect = { option -> + onExpandedChange(false) + onSetType(if (option.id == DETECT_ID) null else ControllerModel.valueOf(option.id)) + }, + ) + } +} + +@Composable +private fun typeOptions(joycon: ConnectedJoycon): List { + val detect = DropdownOption( + DETECT_ID, + stringResource(R.string.type_detect), + subLabel = joycon.detectedModel.label(), + ) + return listOf(detect) + choosableModels.map { DropdownOption(it.name, it.label()) } +} diff --git a/feature/assignment/presentation/src/main/res/values/strings.xml b/feature/assignment/presentation/src/main/res/values/strings.xml index 8bb8164e..9b9027bb 100644 --- a/feature/assignment/presentation/src/main/res/values/strings.xml +++ b/feature/assignment/presentation/src/main/res/values/strings.xml @@ -3,6 +3,16 @@ Left Right Pro + GameCube + Change controller type + Detect automatically + SET MANUALLY + Couldn\'t tell what this controller is. Tap its name to choose. + Left Joy-Con + Right Joy-Con + Pro Controller + GameCube controller + Not recognised CONNECTING… CONNECTION LOST · PRESS SYNC Unassigned Controllers diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 18230547..7ba4dbfc 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -10,7 +10,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel @SuppressLint("MissingPermission") class BleScanner(context: Context) { @@ -18,11 +18,10 @@ class BleScanner(context: Context) { companion object { private const val TAG = "Joycon2" private const val NINTENDO_MANUFACTURER_ID = 0x0553 - private const val SIDE_TYPE_INDEX = 5 private const val SCAN_TIMEOUT_MS = 15_000L } - var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null + var onDeviceFound: ((ScanResult, ControllerModel, String) -> Unit)? = null var onScanFailed: ((Int) -> Unit)? = null var onTimeout: (() -> Unit)? = null @@ -69,8 +68,10 @@ class BleScanner(context: Context) { val name = result.device.name ?: result.scanRecord?.deviceName ?: "Joy-Con 2" - val side = detectSide(result, name) - onDeviceFound?.invoke(result, side, name) + val model = modelFromName(name) + ?: JoyconAdvertisement.model(manufacturerData) + ?: ControllerModel.UNKNOWN + onDeviceFound?.invoke(result, model, name) } override fun onScanFailed(errorCode: Int) { @@ -106,29 +107,10 @@ class BleScanner(context: Context) { .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() - private fun detectSide(result: ScanResult, name: String): Side { - sideFromName(name)?.let { return it } - sideFromManufacturerData(result)?.let { return it } - return Side.UNKNOWN - } - - private fun sideFromName(name: String): Side? = when { - name.contains("(L)") || name.contains("Left") -> Side.LEFT - name.contains("(R)") || name.contains("Right") -> Side.RIGHT - name.contains("Pro") -> Side.PRO + private fun modelFromName(name: String): ControllerModel? = when { + name.contains("(L)") || name.contains("Left") -> ControllerModel.JOYCON_LEFT + name.contains("(R)") || name.contains("Right") -> ControllerModel.JOYCON_RIGHT + name.contains("Pro") -> ControllerModel.PRO_CONTROLLER else -> null } - - /** The product ID's low byte: docs/protocol.md#advertising */ - private fun sideFromManufacturerData(result: ScanResult): Side? { - val mfgData = result.scanRecord - ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null - if (mfgData.size <= SIDE_TYPE_INDEX) return null - return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { - 0x67 -> Side.LEFT - 0x66 -> Side.RIGHT - 0x69 -> Side.PRO - else -> null - } - } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt index 5ebf03e1..0f6c6e8b 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt @@ -16,11 +16,13 @@ import java.util.UUID internal class CommonSession( private val gatt: BluetoothGatt, private val channel: CommonChannel, - private val side: Side, + private val side: () -> Side, private val ops: GattOpQueue, + private val calibrationReads: List, initialLedBitmask: Byte, private val onInput: (JoyconInput) -> Unit, private val onAccentColor: (Int) -> Unit, + private val onCommandReply: (ByteArray) -> Unit, private val onReady: () -> Unit, ) : ControllerProtocol { companion object { @@ -51,9 +53,10 @@ internal class CommonSession( ops.enqueue(INIT_GAP_MS) { write(FEATURES_INIT) } ops.enqueue(INIT_GAP_MS) { write(FEATURES_ENABLE) } ops.enqueue(INIT_GAP_MS) { write(SPI_READ_COLOR) } + read(calibrationReads) ops.enqueue { ready = true - Log.i(TAG, "[$side] Init sequence complete") + Log.i(TAG, "[${side()}] Init sequence complete") onReady() false // no GATT op — advance immediately } @@ -63,6 +66,10 @@ internal class CommonSession( ready = false } + fun read(commands: List) { + commands.forEach { command -> ops.enqueue(INIT_GAP_MS) { write(command) } } + } + /** The console detour ended, so this channel is driving the controller again. */ fun resume() { ready = true @@ -86,11 +93,11 @@ internal class CommonSession( } else { BluetoothGatt.CONNECTION_PRIORITY_BALANCED } - Log.i(TAG, "[$side] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") + Log.i(TAG, "[${side()}] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") } private fun onReport(report: ByteArray) { - PacketParser.parse(report, side)?.let(onInput) + PacketParser.parse(report, side())?.let(onInput) if (!ledSentAfterFirstPacket && ready) { ledSentAfterFirstPacket = true sendPlayerLed() @@ -98,9 +105,10 @@ internal class CommonSession( } private fun onCommandResponse(value: ByteArray) { - Log.d(TAG, "[$side] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + Log.d(TAG, "[${side()}] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + onCommandReply(value) SpiColorParser.parseAccentColor(value)?.let { color -> - Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") + Log.i(TAG, "[${side()}] Accent color: #${"%06X".format(color)}") onAccentColor(color) } } @@ -108,7 +116,7 @@ internal class CommonSession( private fun sendPlayerLed() { ops.enqueue { val cmd = ControllerCommands.playerLed(ledBitmask) - Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") + Log.i(TAG, "[${side()}] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") write(cmd) } } @@ -118,11 +126,11 @@ internal class CommonSession( gatt.setCharacteristicNotification(characteristic, true) val cccd = characteristic.getDescriptor(CommonChannel.CCCD) if (cccd == null) { - Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") + Log.w(TAG, "[${side()}] CMD_RESPONSE char has no CCCD descriptor") return } ops.enqueue { - Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") + Log.d(TAG, "[${side()}] Writing CMD_RESPONSE CCCD") writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } @@ -131,11 +139,11 @@ internal class CommonSession( gatt.setCharacteristicNotification(channel.input, true) val cccd = channel.input.getDescriptor(CommonChannel.CCCD) if (cccd == null) { - Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") + Log.e(TAG, "[${side()}] NOTIFY char has no CCCD descriptor — notifications won't work!") return } ops.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD") + Log.d(TAG, "[${side()}] Writing NOTIFY CCCD") writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index d843f8a3..3f5ffc57 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.bluetooth.le.ScanResult import android.content.Context import com.joegec.joycon2android.connection.console.SecurityRequestReceiver -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import java.util.concurrent.ConcurrentHashMap /** Thread-safe: BLE callbacks arrive on binder threads. */ @@ -24,7 +24,13 @@ class ConnectionPool(private val context: Context) { val size: Int get() = connections.size /** Null for an address already in the pool; one awaiting its controller's return reconnects instead. */ - fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { + fun connect( + result: ScanResult, + detectedModel: ControllerModel, + typeOverride: ControllerModel?, + name: String, + highPriority: Boolean, + ): JoyconConnection? { val address = result.device.address connections[address]?.let { it.reconnect(result.device) @@ -32,7 +38,8 @@ class ConnectionPool(private val context: Context) { } val connection = JoyconConnection( context, - side, + detectedModel, + typeOverride, name, onReady = { reassertOtherPriorities(address) }, ) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt new file mode 100644 index 00000000..b504ec1d --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +// The console report has no trigger bytes: docs/protocol.md#android-workarounds +internal object ConsoleFallback { + fun allows(model: ControllerModel) = !model.hasAnalogTriggers +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt new file mode 100644 index 00000000..267bfdf7 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.joegec.joycon2android.model.ControllerModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.controllerTypeDataStore: DataStore by preferencesDataStore(name = "controller_types") + +class ControllerTypePreferencesDataStore(context: Context) : ControllerTypePreferences { + + private val dataStore = context.applicationContext.controllerTypeDataStore + + override val overrides: Flow> = dataStore.data.map { prefs -> + prefs.asMap().mapNotNull { (key, value) -> + val model = StoredControllerModel.parse(value) ?: return@mapNotNull null + key.name to model + }.toMap() + } + + override suspend fun set(address: String, model: ControllerModel?) { + dataStore.edit { prefs -> + val key = stringPreferencesKey(address) + if (model == null) prefs.remove(key) else prefs[key] = model.name + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt new file mode 100644 index 00000000..516d6b60 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +/** docs/protocol.md#spi-reads */ +object FactoryCalibrationReads { + + // Only the GameCube controller's flash has been checked against its sticks and triggers. + fun commands(model: ControllerModel): List = + if (model != ControllerModel.GAMECUBE) { + emptyList() + } else { + listOf( + spiRead(SpiStickCalibration.MAIN_ADDRESS, SpiStickCalibration.LENGTH), + spiRead(SpiStickCalibration.RIGHT_ADDRESS, SpiStickCalibration.LENGTH), + spiRead(SpiTriggerZeros.ADDRESS, SpiTriggerZeros.LENGTH), + ) + } + + // Transport 0x00, as the colour read sends it. + private fun spiRead(address: Int, length: Int) = ControllerCommands.spiRead(length, address, ControllerCommands.USB) + + /** Null when [reply] adds nothing: another read, or flash left unset. */ + fun update(factory: FactoryCalibration, reply: ByteArray): FactoryCalibration? { + SpiStickCalibration.parse(reply, SpiStickCalibration.MAIN_ADDRESS)?.let { return factory.copy(mainStick = it) } + SpiStickCalibration.parse(reply, SpiStickCalibration.RIGHT_ADDRESS)?.let { return factory.copy(rightStick = it) } + SpiTriggerZeros.parse(reply)?.let { (left, right) -> + return factory.copy(triggerZeroLeft = left, triggerZeroRight = right) + } + return null + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 1b6e4c20..266cbd94 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -5,18 +5,24 @@ import android.bluetooth.le.ScanResult import android.content.Context import com.joegec.joycon2android.connection.data.R import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.Side import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class Joycon2Manager( private val context: Context, private val scope: CoroutineScope, + private val typePreferences: ControllerTypePreferences, ) : ControllerRepository, ConnectionPriorityRepository { companion object { @@ -26,6 +32,8 @@ class Joycon2Manager( private val scanner = BleScanner(context) private val pool = ConnectionPool(context) private val connectionJobs = mutableMapOf() + // Null until the stored overrides load, so no controller connects as the wrong type. + private val typeOverrides = typePreferences.overrides.stateIn(scope, SharingStarted.Eagerly, null) @Volatile private var highPriority = false @@ -47,7 +55,7 @@ class Joycon2Manager( } override fun startScan() { - if (scanner.isScanning) return + if (scanner.isScanning || _scanning.value) return if (pool.size >= MAX_CONNECTIONS) return if (!scanner.isAvailable) { @@ -56,8 +64,11 @@ class Joycon2Manager( } _error.value = null - scanner.start(pool::isLive) _scanning.value = true + scope.launch(Dispatchers.Main.immediate) { + typeOverrides.filterNotNull().first() + if (_scanning.value) scanner.start(pool::isLive) + } } override fun stopScan() { @@ -85,6 +96,13 @@ class Joycon2Manager( if (player != null) connection.setPlayerLed(player) else connection.clearPlayerLed() } + override fun setControllerType(address: String, model: ControllerModel?) { + scope.launch { typePreferences.set(address, model) } + val connection = pool.get(address) ?: return + connection.overrideType(model) + rebuildControllers() + } + override fun setHighPriority(enabled: Boolean) { highPriority = enabled pool.setHighPriority(enabled) @@ -115,6 +133,9 @@ class Joycon2Manager( ConnectedJoycon( address = address, side = connection.side, + model = connection.model, + detectedModel = connection.detectedModel, + typeOverride = connection.typeOverride, deviceName = connection.deviceName, connectionState = connection.connectionState.value, input = connection.input.value, @@ -123,14 +144,15 @@ class Joycon2Manager( } } - private fun onDeviceFound(result: ScanResult, side: Side, name: String) { + private fun onDeviceFound(result: ScanResult, model: ControllerModel, name: String) { if (pool.size >= MAX_CONNECTIONS) { scanner.stop() _scanning.value = false return } - pool.connect(result, side, name, highPriority) ?: return + val typeOverride = typeOverrides.value?.get(result.device.address) + pool.connect(result, model, typeOverride, name, highPriority) ?: return onPoolChanged() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index d19a3461..f49af6de 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,11 +1,26 @@ package com.joegec.joycon2android.connection -/** Bonded-host MAC at bytes [10..15], zeroed while pairing: docs/protocol.md#advertising */ +import com.joegec.joycon2android.model.ControllerModel + +/** Manufacturer data for Nintendo's ID: docs/protocol.md#advertising */ object JoyconAdvertisement { + private const val PRODUCT_ID_LOW = 5 private const val HOST_MAC_OFFSET = 10 private const val HOST_MAC_LENGTH = 6 + fun model(manufacturerData: ByteArray): ControllerModel? { + if (manufacturerData.size <= PRODUCT_ID_LOW) return null + return when (manufacturerData[PRODUCT_ID_LOW].toInt() and 0xFF) { + 0x67 -> ControllerModel.JOYCON_LEFT + 0x66 -> ControllerModel.JOYCON_RIGHT + 0x69 -> ControllerModel.PRO_CONTROLLER + 0x73 -> ControllerModel.GAMECUBE + else -> null + } + } + + // Bonded-host MAC, zeroed while pairing. fun isPairing(manufacturerData: ByteArray): Boolean { if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) 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 fba51189..6e9e2fc0 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 @@ -13,6 +13,7 @@ import android.os.Looper import android.util.Log import com.joegec.joycon2android.connection.console.ConsoleChannel import com.joegec.joycon2android.connection.console.ConsoleSession +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.JoyconConnectionState import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -25,7 +26,8 @@ import java.util.UUID @SuppressLint("MissingPermission") class JoyconConnection( private val context: Context, - val side: Side, + val detectedModel: ControllerModel, + typeOverride: ControllerModel?, val deviceName: String, private val onReady: () -> Unit, private val onDisconnected: (() -> Unit)? = null, @@ -41,6 +43,12 @@ class JoyconConnection( private const val RETURN_WINDOW_MS = 120_000L } + @Volatile var typeOverride: ControllerModel? = typeOverride + private set + val model: ControllerModel get() = typeOverride ?: detectedModel + @Volatile var side: Side = model.defaultSide + private set + private val _connectionState = MutableStateFlow( JoyconConnectionState(connecting = true, deviceName = deviceName) ) @@ -52,7 +60,8 @@ class JoyconConnection( private val mainHandler = Handler(Looper.getMainLooper()) private val ops = GattOpQueue() private val returnWindow = ReturnWindow(RETURN_WINDOW_MS, MainThreadScheduler(), ::giveUpOnReturn) - private val stickCalibrator = StickCalibrator() + @Volatile private var calibrator = InputCalibrator(model) + @Volatile private var factory = FactoryCalibration() private var gatt: BluetoothGatt? = null private var device: BluetoothDevice? = null private var common: CommonSession? = null @@ -74,6 +83,8 @@ class JoyconConnection( val awaitingReturn: Boolean get() = returnWindow.isOpen + private val mayUseConsole: Boolean get() = ConsoleFallback.allows(model) + fun connect(device: BluetoothDevice) { this.device = device gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) @@ -101,6 +112,14 @@ class JoyconConnection( if (initComplete) protocol?.setHighPriority(enabled) } + // A new side reads the sticks from other offsets, so their learned centres no longer apply. + fun overrideType(override: ControllerModel?) { + typeOverride = override + side = model.defaultSide + calibrator = InputCalibrator(model).apply { useFactory(factory) } + if (initComplete) common?.read(FactoryCalibrationReads.commands(model)) + } + fun setPlayerLed(player: PlayerNumber) { assignedPlayer = player protocol?.setPlayerLed(player.ledBitmask) @@ -172,7 +191,7 @@ class JoyconConnection( } servicesDiscovered = true - if (securityRequested) { + if (securityRequested && mayUseConsole) { startConsoleSession(g) return } @@ -188,11 +207,13 @@ class JoyconConnection( common = CommonSession( gatt = g, channel = channel, - side = side, + side = { side }, ops = ops, + calibrationReads = FactoryCalibrationReads.commands(model), initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, onInput = ::publishInput, onAccentColor = ::publishAccentColor, + onCommandReply = ::applyFactoryCalibration, onReady = { onProtocolReady() mainHandler.postDelayed({ fallBackToConsoleIfSilent(g) }, CONSOLE_FALLBACK_MS) @@ -242,14 +263,14 @@ class JoyconConnection( private fun commonChannelAnswered() = commonReportSeen || commandReplySeen private fun fallBackToConsoleIfSilent(g: BluetoothGatt) { - if (commonChannelAnswered()) return + if (commonChannelAnswered() || !mayUseConsole) return Log.i(TAG, "[$side] No reply on the common channel; switching to the console protocol") startConsoleSession(g) } @Synchronized private fun startConsoleSession(g: BluetoothGatt) { - if (console != null || commonReportSeen || gatt !== g) return + if (!mayUseConsole || console != null || commonReportSeen || gatt !== g) return val channel = ConsoleChannel.find(g) if (channel == null) { fail("Not a compatible Joy-Con 2") @@ -336,7 +357,14 @@ class JoyconConnection( } private fun publishInput(input: JoyconInput) { - _input.value = stickCalibrator.calibrate(input) + _input.value = calibrator.calibrate(input) + } + + private fun applyFactoryCalibration(reply: ByteArray) { + FactoryCalibrationReads.update(factory, reply)?.let { + factory = it + calibrator.useFactory(it) + } } private fun publishAccentColor(color: Int) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index 2d5bf264..14adbc3e 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -10,6 +10,8 @@ import java.nio.ByteOrder object PacketParser { private const val MIN_PACKET_SIZE = 0x3B + private const val TRIGGER_LEFT = 0x3C + private const val TRIGGER_RIGHT = 0x3D // docs/protocol.md#packet-layout. The back-paddle byte (0x07) is folded into bits 32..39. private val buttonMasks: List> = listOf( @@ -48,6 +50,8 @@ object PacketParser { gyroY = bb.getShort(0x38).toInt(), gyroZ = bb.getShort(0x3A).toInt(), battery = BatteryCharge.fromVolts((bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f), + triggerLeft = uint8OrZero(data, TRIGGER_LEFT), + triggerRight = uint8OrZero(data, TRIGGER_RIGHT), ) } @@ -81,6 +85,9 @@ object PacketParser { return (v and 0xFFF) to ((v shr 12) and 0xFFF) } + private fun uint8OrZero(data: ByteArray, offset: Int): Int = + if (offset < data.size) data[offset].toInt() and 0xFF else 0 + private fun decodeUint24(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) or diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt index e53170c6..67130ed0 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt @@ -3,33 +3,11 @@ package com.joegec.joycon2android.connection /** Pulls the shell accent colour out of an SPI-flash read reply: docs/protocol.md#spi-reads. */ object SpiColorParser { - private const val REPORT_TYPE_SPI = 0x02 - private const val COMMAND_SPI_READ = 0x04 - private const val ADDRESS_OFFSET = 0x0C - private const val DATA_OFFSET = 0x10 - const val ACCENT_COLOR_ADDRESS = 0x01301F /** Packed 0xRRGGBB, or null if this is not an SPI read or does not span the accent address. */ fun parseAccentColor(reply: ByteArray): Int? { - if (reply.size < DATA_OFFSET) return null - if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null - if (reply[3].toInt() and 0xFF != COMMAND_SPI_READ) return null - - val baseAddress = readLeUInt32(reply, ADDRESS_OFFSET) - val colorOffset = DATA_OFFSET + (ACCENT_COLOR_ADDRESS - baseAddress) - if (colorOffset < DATA_OFFSET || colorOffset + 3 > reply.size) return null - - val r = reply[colorOffset].toInt() and 0xFF - val g = reply[colorOffset + 1].toInt() and 0xFF - val b = reply[colorOffset + 2].toInt() and 0xFF - return (r shl 16) or (g shl 8) or b - } - - private fun readLeUInt32(data: ByteArray, offset: Int): Int { - return (data[offset].toInt() and 0xFF) or - ((data[offset + 1].toInt() and 0xFF) shl 8) or - ((data[offset + 2].toInt() and 0xFF) shl 16) or - ((data[offset + 3].toInt() and 0xFF) shl 24) + val rgb = SpiReadReply.bytesAt(reply, ACCENT_COLOR_ADDRESS, 3) ?: return null + return ((rgb[0].toInt() and 0xFF) shl 16) or ((rgb[1].toInt() and 0xFF) shl 8) or (rgb[2].toInt() and 0xFF) } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt new file mode 100644 index 00000000..82863164 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt @@ -0,0 +1,27 @@ +package com.joegec.joycon2android.connection + +/** docs/protocol.md#spi-reads */ +object SpiReadReply { + + private const val REPORT_TYPE_SPI = 0x02 + private const val COMMAND_SPI_READ = 0x04 + private const val ADDRESS_OFFSET = 0x0C + private const val DATA_OFFSET = 0x10 + + /** The [length] bytes at [address], or null if [reply] is not an SPI read that spans them. */ + fun bytesAt(reply: ByteArray, address: Int, length: Int): ByteArray? { + if (reply.size < DATA_OFFSET) return null + if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null + if (reply[3].toInt() and 0xFF != COMMAND_SPI_READ) return null + + val start = DATA_OFFSET + (address - readLeUInt32(reply, ADDRESS_OFFSET)) + if (start < DATA_OFFSET || start + length > reply.size) return null + return reply.copyOfRange(start, start + length) + } + + private fun readLeUInt32(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + ((data[offset + 2].toInt() and 0xFF) shl 16) or + ((data[offset + 3].toInt() and 0xFF) shl 24) +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt new file mode 100644 index 00000000..f55a95bc --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt @@ -0,0 +1,33 @@ +package com.joegec.joycon2android.connection + +/** 9 bytes: centre, span above, span below, each an X/Y pair packed in 12 bits. docs/protocol.md#spi-reads */ +object SpiStickCalibration { + + const val MAIN_ADDRESS = 0x0130A8 + const val RIGHT_ADDRESS = 0x0130E8 + const val LENGTH = 9 + + // Unset flash reads 0xFFF; anything outside these is not a stick this app knows. + private val CENTRE_RANGE = 1024..3072 + private val SPAN_RANGE = 500..2000 + + /** Null when [reply] isn't a read of [address], or the flash there is unset or implausible. */ + fun parse(reply: ByteArray, address: Int): StickCalibration? { + val bytes = SpiReadReply.bytesAt(reply, address, LENGTH) ?: return null + val (centreX, centreY) = pair(bytes, 0) + val (aboveX, aboveY) = pair(bytes, 3) + val (belowX, belowY) = pair(bytes, 6) + return StickCalibration(centreX, centreY, aboveX, aboveY, belowX, belowY).takeIf(::plausible) + } + + private fun pair(bytes: ByteArray, offset: Int): Pair { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val b2 = bytes[offset + 2].toInt() and 0xFF + return (b0 or ((b1 and 0x0F) shl 8)) to ((b1 shr 4) or (b2 shl 4)) + } + + private fun plausible(c: StickCalibration): Boolean = + c.centreX in CENTRE_RANGE && c.centreY in CENTRE_RANGE && + listOf(c.aboveX, c.aboveY, c.belowX, c.belowY).all { it in SPAN_RANGE } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt new file mode 100644 index 00000000..d2e0c806 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.connection + +/** Factory trigger zero points, left then right: docs/protocol.md#analog-triggers */ +object SpiTriggerZeros { + + const val ADDRESS = 0x013140 + const val LENGTH = 2 + private const val UNSET = 0xFF + + /** Null when [reply] isn't this read; a null zero when flash leaves it unset. */ + fun parse(reply: ByteArray): Pair? { + val zeros = SpiReadReply.bytesAt(reply, ADDRESS, LENGTH) ?: return null + return zero(zeros[0]) to zero(zeros[1]) + } + + private fun zero(byte: Byte): Int? = (byte.toInt() and 0xFF).takeIf { it != UNSET } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt new file mode 100644 index 00000000..69077489 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +// A side saved by an earlier build, or a model the menu can't choose, reads as no override. +internal object StoredControllerModel { + fun parse(value: Any?): ControllerModel? = + ControllerModel.entries.firstOrNull { it.name == value && it != ControllerModel.UNKNOWN } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt new file mode 100644 index 00000000..00452092 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt @@ -0,0 +1,21 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConsoleFallbackTest { + + @Test + fun `a controller with analog triggers never falls back to the console protocol`() { + assertFalse(ConsoleFallback.allows(ControllerModel.GAMECUBE)) + } + + @Test + fun `a Joy-Con or an unrecognised controller still can`() { + assertTrue(ConsoleFallback.allows(ControllerModel.JOYCON_LEFT)) + assertTrue(ConsoleFallback.allows(ControllerModel.JOYCON_RIGHT)) + assertTrue(ConsoleFallback.allows(ControllerModel.UNKNOWN)) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt index 53dd68de..46d31e20 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt @@ -1,6 +1,9 @@ package com.joegec.joycon2android.connection +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -8,6 +11,8 @@ class JoyconAdvertisementTest { // Captured from a right Joy-Con 2: SYNC held (pairing) vs A pressed (wake) private val pairing = bytes("01 00 03 7E 05 66 20 00 01 00 00 00 00 00 00 00 0F 00 00 00 00 00 00 00") + // NSO GameCube controller, SYNC held (Retroid Pocket Nova capture, 2026-10-07) + private val gameCube = bytes("01 00 03 7E 05 73 20 00 01 00 00 00 00 00 00 00 0F 00 00 00 00 00 00 00") private val wake = bytes("01 00 03 7E 05 66 20 00 01 00 09 A7 9A 55 E2 98 0F 00 00 00 00 00 00 00") @Test @@ -25,6 +30,18 @@ class JoyconAdvertisementTest { assertTrue(JoyconAdvertisement.isPairing(ByteArray(4))) } + @Test + fun `the product ID names the model`() { + assertEquals(ControllerModel.JOYCON_RIGHT, JoyconAdvertisement.model(pairing)) + assertEquals(ControllerModel.GAMECUBE, JoyconAdvertisement.model(gameCube)) + } + + @Test + fun `an unknown product ID or short data names no model`() { + assertNull(JoyconAdvertisement.model(bytes("01 00 03 7E 05 99 20"))) + assertNull(JoyconAdvertisement.model(ByteArray(4))) + } + private fun bytes(hex: String): ByteArray = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() } diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt index af47f7a4..4115a6c1 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -64,4 +64,22 @@ class PacketParserTest { assertTrue(JoyconButton.GR.id !in input.pressed) assertTrue(JoyconButton.GL.id !in input.pressed) } + + @Test + fun `analog triggers decode raw from bytes 0x3C and 0x3D`() { + val data = ByteArray(63).apply { + this[0x3C] = 0xEA.toByte() + this[0x3D] = 0x22 + } + val input = PacketParser.parse(data, Side.PRO)!! + assertEquals(0xEA, input.triggerLeft) + assertEquals(0x22, input.triggerRight) + } + + @Test + fun `triggers read zero from a packet too short to carry them`() { + val input = PacketParser.parse(ByteArray(0x3C), Side.PRO)!! + assertEquals(0, input.triggerLeft) + assertEquals(0, input.triggerRight) + } } diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt new file mode 100644 index 00000000..e3b13e11 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt @@ -0,0 +1,64 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SpiStickCalibrationTest { + + // A GameCube controller's main stick as captured: rest 2073,2060, travel 835..3297 x 811..3258. + private val mainStick = StickCalibration( + centreX = 2073, centreY = 2060, aboveX = 1224, aboveY = 1198, belowX = 1238, belowY = 1249, + ) + + @Test + fun `the nine bytes decode as centre, span above and span below`() { + assertEquals(mainStick, SpiStickCalibration.parse(reply(SpiStickCalibration.MAIN_ADDRESS, mainStick), SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `unset flash reads as no calibration`() { + val unset = reply(SpiStickCalibration.MAIN_ADDRESS, ByteArray(9) { 0xFF.toByte() }) + assertNull(SpiStickCalibration.parse(unset, SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `a reply for the other stick is not this read`() { + assertNull(SpiStickCalibration.parse(reply(SpiStickCalibration.RIGHT_ADDRESS, mainStick), SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `only a gamecube controller reads its flash, both sticks and the trigger zeros`() { + assertEquals(0, FactoryCalibrationReads.commands(ControllerModel.JOYCON_RIGHT).size) + assertEquals(0, FactoryCalibrationReads.commands(ControllerModel.PRO_CONTROLLER).size) + assertEquals(3, FactoryCalibrationReads.commands(ControllerModel.GAMECUBE).size) + } + + @Test + fun `each reply fills its own part of the factory calibration`() { + val withMain = FactoryCalibrationReads.update(FactoryCalibration(), reply(SpiStickCalibration.MAIN_ADDRESS, mainStick))!! + val withRight = FactoryCalibrationReads.update(withMain, reply(SpiStickCalibration.RIGHT_ADDRESS, mainStick))!! + assertEquals(mainStick, withRight.mainStick) + assertEquals(mainStick, withRight.rightStick) + assertNull(withRight.triggerZeroLeft) + } + + @Test + fun `a reply carrying nothing it reads adds nothing`() { + assertNull(FactoryCalibrationReads.update(FactoryCalibration(), reply(0x013000, ByteArray(9)))) + } + + private fun reply(address: Int, calibration: StickCalibration): ByteArray = reply( + address, + pack(calibration.centreX, calibration.centreY) + pack(calibration.aboveX, calibration.aboveY) + + pack(calibration.belowX, calibration.belowY), + ) + + private fun reply(address: Int, data: ByteArray): ByteArray = + byteArrayOf(0x02, 0x01, 0x00, 0x04, 0x10, 0x78, 0x00, 0x00, data.size.toByte(), 0x00, 0x00, 0x00) + + byteArrayOf(address.toByte(), (address shr 8).toByte(), (address shr 16).toByte(), 0x00) + data + + private fun pack(x: Int, y: Int): ByteArray = + byteArrayOf(x.toByte(), ((x shr 8) and 0x0F or ((y and 0x0F) shl 4)).toByte(), (y shr 4).toByte()) +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt new file mode 100644 index 00000000..39ce14d3 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt @@ -0,0 +1,36 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SpiTriggerZerosTest { + + // Reply from an NSO GameCube controller to a 0x40-byte read at 0x013140 (Retroid Pocket Nova, 2026-10-07) + private val reply = bytes("02 01 00 04 10 78 00 00 40 00 00 00 40 31 01 00 21 20" + " FF".repeat(62)) + + @Test + fun `the first two bytes are the left and right zero points`() { + assertEquals(33 to 32, SpiTriggerZeros.parse(reply)) + } + + @Test + fun `an unset zero reads as null`() { + val unset = reply.copyOf().apply { this[16] = 0xFF.toByte() } + assertEquals(null to 32, SpiTriggerZeros.parse(unset)) + } + + @Test + fun `a reply for another address is not this read`() { + val colour = reply.copyOf().apply { this[13] = 0x30 } + assertNull(SpiTriggerZeros.parse(colour)) + } + + @Test + fun `the colour parser still finds the accent in a device info block`() { + val info = bytes("02 01 00 04 10 78 00 00 40 00 00 00 00 30 01 00" + " 00".repeat(0x1F) + " 12 34 56" + " 00".repeat(0x1D)) + assertEquals(0x123456, SpiColorParser.parseAccentColor(info)) + } + + private fun bytes(hex: String): ByteArray = hex.trim().split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt new file mode 100644 index 00000000..5325e65b --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt @@ -0,0 +1,28 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StoredControllerModelTest { + + @Test + fun `every model the menu offers reads back as itself`() { + (ControllerModel.entries - ControllerModel.UNKNOWN).forEach { model -> + assertEquals(model, StoredControllerModel.parse(model.name)) + } + } + + @Test + fun `a side saved by an earlier build reads as no override`() { + assertNull(StoredControllerModel.parse("PRO")) + assertNull(StoredControllerModel.parse("LEFT")) + } + + @Test + fun `unknown, or anything that isn't a model name, reads as no override`() { + assertNull(StoredControllerModel.parse("UNKNOWN")) + assertNull(StoredControllerModel.parse(42)) + } +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt index 223ebb84..6d10374e 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.flow.StateFlow /** [controllers] re-emits on every input or state change. */ @@ -15,5 +16,8 @@ interface ControllerRepository { fun disconnect(address: String) fun disconnectAll() fun setPlayerLed(address: String, player: PlayerNumber?) + + /** Null returns the controller to the type its advertisement named. */ + fun setControllerType(address: String, model: ControllerModel?) fun emitError(message: String) } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt new file mode 100644 index 00000000..af84df77 --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import kotlinx.coroutines.flow.Flow + +interface ControllerTypePreferences { + val overrides: Flow> + suspend fun set(address: String, model: ControllerModel?) +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt new file mode 100644 index 00000000..b618e45f --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +/** What the controller's SPI flash holds; null where it is unread or unset. docs/protocol.md#spi-reads */ +data class FactoryCalibration( + val mainStick: StickCalibration? = null, + val rightStick: StickCalibration? = null, + val triggerZeroLeft: Int? = null, + val triggerZeroRight: Int? = null, +) diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt new file mode 100644 index 00000000..e7650345 --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt @@ -0,0 +1,29 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.JoyconInput + +class InputCalibrator(private val model: ControllerModel) { + + private val sticks = + if (model == ControllerModel.GAMECUBE) StickCalibrator(rightSeedHalfSpan = C_STICK_SEED_HALF_SPAN) + else StickCalibrator() + private val triggers = TriggerCalibrator() + + fun useFactory(factory: FactoryCalibration) { + sticks.useFactory(factory.mainStick, factory.rightStick) + triggers.setFactoryZeros(factory.triggerZeroLeft, factory.triggerZeroRight) + } + + // Bytes 0x3C/0x3D carry nothing meaningful on a controller without analog triggers. + fun calibrate(input: JoyconInput): JoyconInput { + val calibrated = sticks.calibrate(input) + return if (model.hasAnalogTriggers) triggers.calibrate(calibrated) + else calibrated.copy(triggerLeft = 0, triggerRight = 0) + } + + private companion object { + // Just under the shortest C-stick travel measured, 1022 (three units, 2026-10-07). + const val C_STICK_SEED_HALF_SPAN = 1000 + } +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt new file mode 100644 index 00000000..1ff3210b --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.connection + +/** Raw 12-bit units: the resting centre, then how far the stick travels above and below it per axis. */ +data class StickCalibration( + val centreX: Int, + val centreY: Int, + val aboveX: Int, + val aboveY: Int, + val belowX: Int, + val belowY: Int, +) 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 index 5182f972..dac70438 100644 --- 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 @@ -7,12 +7,24 @@ class StickCalibrator( restWindowSize: Int = DEFAULT_REST_WINDOW, maxRestSpreadLsb: Int = DEFAULT_MAX_REST_SPREAD, seedHalfSpan: Int = DEFAULT_SEED_HALF_SPAN, + rightSeedHalfSpan: Int = seedHalfSpan, ) { 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) + private val rightX = Axis(restWindowSize, maxRestSpreadLsb, rightSeedHalfSpan) + private val rightY = Axis(restWindowSize, maxRestSpreadLsb, rightSeedHalfSpan) + + fun useFactory(main: StickCalibration?, right: StickCalibration?) { + main?.let { + leftX.useFactory(it.centreX, it.aboveX, it.belowX) + leftY.useFactory(it.centreY, it.aboveY, it.belowY) + } + right?.let { + rightX.useFactory(it.centreX, it.aboveX, it.belowX) + rightY.useFactory(it.centreY, it.aboveY, it.belowY) + } + } fun calibrate(input: JoyconInput): JoyconInput = input.copy( stickX = leftX.rescale(input.stickX), @@ -36,6 +48,14 @@ class StickCalibrator( private var min = 0 private var max = 0 + // Spans still widen past what the factory measured. + fun useFactory(centre: Int, above: Int, below: Int) { + this.centre = centre + centreLearned = true + this.above = above + this.below = below + } + fun rescale(raw: Int): Int { learnCentre(raw) val delta = raw - centre diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt new file mode 100644 index 00000000..b91014d2 --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt @@ -0,0 +1,62 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconInput +import kotlin.math.abs + +/** Raw trigger bytes onto 0..255, full at the first stop: docs/protocol.md#analog-triggers */ +class TriggerCalibrator( + seedFull: Int = DEFAULT_SEED_FULL, + deadZone: Int = DEFAULT_DEAD_ZONE, +) { + + private val left = Axis(seedFull, deadZone) + private val right = Axis(seedFull, deadZone) + + fun setFactoryZeros(left: Int?, right: Int?) { + this.left.factoryZero = left + this.right.factoryZero = right + } + + fun calibrate(input: JoyconInput): JoyconInput = input.copy( + triggerLeft = left.rescale(input.triggerLeft, clicked = JoyconButton.L.id in input.pressed), + triggerRight = right.rescale(input.triggerRight, clicked = JoyconButton.R.id in input.pressed), + ) + + private class Axis(private var full: Int, private val deadZone: Int) { + var factoryZero: Int? = null + private var lowestSeen = Int.MAX_VALUE + private var heldValue = -1 + private var heldPackets = 0 + + fun rescale(raw: Int, clicked: Boolean): Int { + lowestSeen = minOf(lowestSeen, raw) + if (!clicked) learnFull(raw) + val start = (factoryZero ?: lowestSeen) + deadZone + if (raw <= start) return 0 + return ((raw - start) * MAX / (full - start).coerceAtLeast(1)).coerceAtMost(MAX) + } + + // Only a held value counts: a quick press passes far beyond the first stop on its way to the click. + private fun learnFull(raw: Int) { + if (abs(raw - heldValue) <= HOLD_JITTER) heldPackets++ else { + heldValue = raw + heldPackets = 1 + } + if (heldPackets >= HOLD_PACKETS && raw in (full + 1)..FULL_CEILING) full = raw + } + } + + companion object { + private const val MAX = 255 + + // Measured on three units (2026-10-07): first stops 170-196, rest up to 4 above the factory zero. + private const val DEFAULT_SEED_FULL = 170 + private const val DEFAULT_DEAD_ZONE = 5 + private const val FULL_CEILING = 205 + + // ~150 ms at the 30 ms balanced interval. + private const val HOLD_PACKETS = 5 + private const val HOLD_JITTER = 2 + } +} diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt new file mode 100644 index 00000000..b8649bba --- /dev/null +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt @@ -0,0 +1,36 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.JoyconInput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputCalibratorTest { + + @Test + fun `a controller without analog triggers reports none`() { + val calibrated = InputCalibrator(ControllerModel.PRO_CONTROLLER).calibrate(JoyconInput(triggerLeft = 120, triggerRight = 90)) + assertEquals(0, calibrated.triggerLeft) + assertEquals(0, calibrated.triggerRight) + } + + @Test + fun `a gamecube c-stick reaches full tilt at its shorter travel`() { + val calibrator = InputCalibrator(ControllerModel.GAMECUBE) + assertEquals(4095, calibrator.calibrate(JoyconInput(rightStickY = 2048 + 1022)).rightStickY) + } + + @Test + fun `a pro controller's right stick keeps the joy-con seed`() { + val calibrator = InputCalibrator(ControllerModel.PRO_CONTROLLER) + assertTrue(calibrator.calibrate(JoyconInput(rightStickY = 2048 + 1022)).rightStickY < 4095) + } + + @Test + fun `a gamecube controller's triggers are calibrated`() { + val calibrator = InputCalibrator(ControllerModel.GAMECUBE) + .apply { useFactory(FactoryCalibration(triggerZeroLeft = 33, triggerZeroRight = 32)) } + assertEquals(255, calibrator.calibrate(JoyconInput(triggerLeft = 180)).triggerLeft) + } +} 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 index 47239698..134666bb 100644 --- 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 @@ -103,4 +103,14 @@ class StickCalibratorTest { assertEquals(2048, rescale(restX)) assertEquals(2048, other.calibrate(JoyconInput(stickX = 2014)).stickX) } + + @Test + fun `factory calibration applies at once, without waiting to learn the centre`() { + val factory = StickCalibration(centreX = 2073, centreY = 2060, aboveX = 1224, aboveY = 1198, belowX = 1238, belowY = 1249) + val calibrated = StickCalibrator().apply { useFactory(factory, null) } + + assertEquals(2048, calibrated.calibrate(JoyconInput(stickX = 2073)).stickX) + assertEquals(4095, calibrated.calibrate(JoyconInput(stickX = 2073 + 1224)).stickX) + assertEquals(0, calibrated.calibrate(JoyconInput(stickY = 2060 - 1249)).stickY) + } } diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt new file mode 100644 index 00000000..f4e086ed --- /dev/null +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt @@ -0,0 +1,54 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconInput +import org.junit.Assert.assertEquals +import org.junit.Test + +class TriggerCalibratorTest { + + private val calibrator = TriggerCalibrator().apply { setFactoryZeros(33, 32) } + + private fun left(raw: Int, clicked: Boolean = false) = calibrator.calibrate( + JoyconInput(triggerLeft = raw, pressed = if (clicked) setOf(JoyconButton.L.id) else emptySet()), + ).triggerLeft + + @Test + fun `rest a few counts above the factory zero reads as released`() { + assertEquals(0, left(37)) + } + + @Test + fun `travel scales up to the seeded first stop`() { + assertEquals(255, left(170)) + assertEquals(127, left(104)) // halfway between 38 and 170 + } + + @Test + fun `a held first stop beyond the seed becomes full`() { + repeat(5) { left(194) } + assertEquals(255, left(194)) + assertEquals(183, left(150)) + } + + @Test + fun `a quick press through to the click does not move full`() { + listOf(120, 190, 220).forEach { left(it) } + assertEquals(255, left(170)) + } + + @Test + fun `a value held while clicked does not move full`() { + repeat(5) { left(200, clicked = true) } + assertEquals(255, left(170)) + } + + @Test + fun `without a factory zero the lowest value seen stands in`() { + val fresh = TriggerCalibrator() + val input = { raw: Int -> fresh.calibrate(JoyconInput(triggerLeft = raw)).triggerLeft } + input(40) + assertEquals(0, input(44)) + assertEquals(255, input(170)) + } +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt index e27bbe76..0057a00f 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt @@ -60,14 +60,14 @@ fun CompactPlayerRow( Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { when { playerState.hasPro -> - ControllerChip(R.string.controller_pro, playerState.left!!, onUnassign) + ControllerChip(playerState.left!!.modelLabel(), playerState.left!!, onUnassign) playerState.hasFullController -> { - ControllerChip(R.string.controller_left, playerState.left!!, onUnassign, batteryFirst = true) - ControllerChip(R.string.controller_right, playerState.right!!, onUnassign) + ControllerChip(stringResource(R.string.controller_left), playerState.left!!, onUnassign, batteryFirst = true) + ControllerChip(stringResource(R.string.controller_right), playerState.right!!, onUnassign) } else -> { - playerState.left?.let { ControllerChip(R.string.controller_left, it, onUnassign) } - playerState.right?.let { ControllerChip(R.string.controller_right, it, onUnassign) } + playerState.left?.let { ControllerChip(stringResource(R.string.controller_left), it, onUnassign) } + playerState.right?.let { ControllerChip(stringResource(R.string.controller_right), it, onUnassign) } } } } @@ -82,7 +82,7 @@ fun CompactPlayerRow( @Composable private fun ControllerChip( - textRes: Int, + text: String, joycon: ConnectedJoycon, onUnassign: (String) -> Unit, batteryFirst: Boolean = false, @@ -114,7 +114,7 @@ private fun ControllerChip( if (batteryFirst) battery() Text( - stringResource(textRes), + text, color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), style = MaterialTheme.typography.titleMedium, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt new file mode 100644 index 00000000..d85627b0 --- /dev/null +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.connection.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.controller_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.controller_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.controller_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.controller_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt index 76de6df7..55aae0a4 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.joegec.joycon2android.model.GameCubeButtons import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.ui.theme.Dimens @@ -21,66 +22,81 @@ internal fun ProControllerLayout( modifier: Modifier = Modifier, ) { val pressed = state.pressed + val buttons = if (state.hasGameCube) GameCubeButtons.present else JoyconButton.entries.toSet() + val marking = { button: JoyconButton -> + (if (state.hasGameCube) GameCubeButtons.marking(button) else null) ?: button.label + } JoyconCard( controller = state.left, onClick = { onUnassign(state.left!!.address) }, modifier = modifier.fillMaxWidth(), ) { - ShoulderButtons(pressed) - ButtonRow(state, pressed) - SticksRow(state, pressed) + ShoulderButtons(pressed, shoulders(state.hasGameCube), marking) + ButtonRow(state, pressed, buttons) + SticksRow(state, pressed, buttons) SpecialButtons(pressed) SidewaysImuDisplay(state.leftInput) } } +// A GameCube controller's L and R are its triggers, so they sit above ZL and Z. +private fun shoulders(gameCube: Boolean): Pair, List> = + if (gameCube) listOf(JoyconButton.L, JoyconButton.ZL) to listOf(JoyconButton.R, JoyconButton.ZR) + else listOf(JoyconButton.ZL, JoyconButton.L, JoyconButton.GL) to listOf(JoyconButton.ZR, JoyconButton.R, JoyconButton.GR) + @Composable -private fun ShoulderButtons(pressed: Set) { +private fun ShoulderButtons( + pressed: Set, + shoulders: Pair, List>, + marking: (JoyconButton) -> String, +) { Row( Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), - ) { - ShoulderButton(JoyconButton.ZL.label, JoyconButton.ZL.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.L.label, JoyconButton.L.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.GL.label, JoyconButton.GL.id in pressed, Modifier.fillMaxWidth()) - } - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), - ) { - ShoulderButton(JoyconButton.ZR.label, JoyconButton.ZR.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.R.label, JoyconButton.R.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.GR.label, JoyconButton.GR.id in pressed, Modifier.fillMaxWidth()) - } + ShoulderColumn(shoulders.first, pressed, marking, Modifier.weight(1f)) + ShoulderColumn(shoulders.second, pressed, marking, Modifier.weight(1f)) } } @Composable -private fun ButtonRow(state: PlayerState, pressed: Set) { +private fun ShoulderColumn( + buttons: List, + pressed: Set, + marking: (JoyconButton) -> String, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap)) { + buttons.forEach { ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) } + } +} + +// The ends are weighted so the battery stays centred when − is missing. +@Composable +private fun ButtonRow(state: PlayerState, pressed: Set, buttons: Set) { Row( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (JoyconButton.Minus in buttons) SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) + } state.leftInput.battery?.let { BatteryPill(it) } - SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) + } } } @Composable -private fun SticksRow(state: PlayerState, pressed: Set) { +private fun SticksRow(state: PlayerState, pressed: Set, buttons: Set) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - StickCard(state.leftStickX, state.leftStickY, JoyconButton.LS.id in pressed, + StickCard(state.leftStickX, state.leftStickY, JoyconButton.LS in buttons && JoyconButton.LS.id in pressed, canvasSize = Dimens.sidewaysStickSize) } Column( @@ -91,7 +107,7 @@ private fun SticksRow(state: PlayerState, pressed: Set) { FaceButtons(pressed, buttonSize = Dimens.sidewaysFaceSize) } Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - StickCard(state.rightStickX, state.rightStickY, JoyconButton.RS.id in pressed, + StickCard(state.rightStickX, state.rightStickY, JoyconButton.RS in buttons && JoyconButton.RS.id in pressed, canvasSize = Dimens.sidewaysStickSize) } } diff --git a/feature/connection/presentation/src/main/res/values/strings.xml b/feature/connection/presentation/src/main/res/values/strings.xml index aa508897..935a0244 100644 --- a/feature/connection/presentation/src/main/res/values/strings.xml +++ b/feature/connection/presentation/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Left Right Pro + GameCube Remove player Connection lost Connection lost. Press SYNC on the controller to bring it back. diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index f93e6fae..f886662a 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -102,7 +102,7 @@ object DolphinWiimoteConfig { // A left Joy-Con's L/ZL edge is already a sideways remote's nose, so only a right one differs. private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { - JoyconSide.DUAL -> emptyMap() + JoyconSide.DUAL, JoyconSide.GAMECUBE -> emptyMap() JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS JoyconSide.RIGHT -> if (sidewaysRemote) SIDEWAYS_REMOTE_INPUTS else RIGHT_BODY_INPUTS } @@ -256,7 +256,7 @@ object DolphinWiimoteConfig { } private fun lines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List { - val sideways = sidewaysRemote && side != JoyconSide.DUAL + val sideways = sidewaysRemote && side.isLone val buttonLines = (mapping.toSourceMap() - WiimoteButton.Shake) .mapNotNull { (target, sources) -> expressionFor(side, sidewaysRemote, sources) diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index c2ff1d62..6b302c2a 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.toSourceMap @@ -90,7 +91,7 @@ object EdenDsuConfig { val slot = player.player.index - 1 if (slot !in 0 until DsuSlots.COUNT) return@forEach val type = EdenControls.npadType(player) ?: return@forEach - val side = sideFor(player) ?: return@forEach + val side = player.joyconSide() ?: return@forEach val mapping = mappingFor(PlayerBody(player.player, side)) val device = device(slot) @@ -155,11 +156,4 @@ object EdenDsuConfig { } return "axis:$axis,threshold:0.5,invert:$invert" } - - private fun sideFor(player: PlayerState): JoyconSide? = when { - player.hasPro || player.hasFullController -> JoyconSide.DUAL - player.left != null -> JoyconSide.LEFT - player.right != null -> JoyconSide.RIGHT - else -> null - } } diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 2f380d8c..fc4c3b25 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -194,4 +195,18 @@ class EdenDsuConfigTest { assertTrue(result.contains("[Core]")) assertEquals("true", valueOf(result, "use_multi_core")) } + + @Test + fun `a gamecube controller's ZL and Z bind to the bumper bits and its trigger clicks to the stick-click bits`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertEquals("\"${device(0)},button:1024\"", valueOf(result, "player_0_button_zl")) // L + assertEquals("\"${device(0)},button:2048\"", valueOf(result, "player_0_button_zr")) // R + assertEquals("\"${device(0)},button:2\"", valueOf(result, "player_0_button_l")) // LS + assertEquals("\"${device(0)},button:4\"", valueOf(result, "player_0_button_r")) // RS + assertFalse(result.contains("player_0_button_lstick")) + assertFalse(result.contains("player_0_button_rstick")) + } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index da1f4066..391961c2 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -62,10 +62,8 @@ object ReportMapper { // Bytes 9-10: right stick Y (inverted) putInt16LE(report, 9, mapStick(4096 - gamepad.rightStickY)) - // Byte 11: left trigger / brake (digital: 0 or 255) - report[11] = if (JoyconButton.ZL.id in pressed) 0xFF.toByte() else 0x00 - // Byte 12: right trigger / accelerator (digital: 0 or 255) - report[12] = if (JoyconButton.ZR.id in pressed) 0xFF.toByte() else 0x00 + report[11] = gamepad.leftTrigger.toByte() + report[12] = gamepad.rightTrigger.toByte() report[13] = bits(pressed, OVERFLOW_MAP).toByte() diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index d475fa13..8b5b5fc7 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -3,6 +3,7 @@ package com.joegec.joycon2android.gamepad import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -95,4 +96,34 @@ class ReportMapperTest { assertEquals(0.toByte(), report[14]) assertEquals(0b10000.toByte(), report[1]) // Home, bit 12 } + + @Test + fun `a gamecube controller's analog travel drives the trigger bytes, not ZL or ZR`() { + val input = JoyconInput(pressed = setOf(JoyconButton.ZL.id), triggerLeft = 128, triggerRight = 40) + val gameCube = ConnectedJoycon( + address = "gc", side = Side.PRO, model = ControllerModel.GAMECUBE, deviceName = "Joy-Con 2", input = input, + ) + val report = ReportMapper.buildReport(GamepadFrame(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + assertEquals(128.toByte(), report[11]) + assertEquals(40.toByte(), report[12]) + } + + @Test + fun `a gamecube controller's ZL and Z travel on the bumper bits, clear of L2 and R2`() { + assertEquals((1 shl 6) or (1 shl 7), gameCubeButtonWord(JoyconButton.ZL, JoyconButton.ZR)) // BUTTON_L1, BUTTON_R1 + } + + @Test + fun `a gamecube controller's trigger clicks travel on the stick-click bits`() { + assertEquals((1 shl 13) or (1 shl 14), gameCubeButtonWord(JoyconButton.L, JoyconButton.R)) // BUTTON_THUMBL, BUTTON_THUMBR + } + + private fun gameCubeButtonWord(vararg pressed: JoyconButton): Int { + val input = JoyconInput(pressed = pressed.map { it.id }.toSet()) + val gameCube = ConnectedJoycon( + address = "gc", side = Side.PRO, model = ControllerModel.GAMECUBE, deviceName = "Joy-Con 2", input = input, + ) + val report = ReportMapper.buildReport(GamepadFrame(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + return (report[0].toInt() and 0xFF) or ((report[1].toInt() and 0xFF) shl 8) + } } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 2b22a53a..afde8ce5 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.emittedStick import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick @@ -68,6 +69,12 @@ object DolphinGcpadConfig { private val STICK_PREFIXES = mapOf(GameCubeStick.MainStick to "Main Stick", GameCubeStick.CStick to "C-Stick") + // AXIS_BRAKE (23) and AXIS_GAS (22): docs/virtual-gamepad.md#analog-triggers + private val ANALOG_TRIGGER_LINES = listOf( + "Triggers/L-Analog = `Axis 23+`", + "Triggers/R-Analog = `Axis 22+`", + ) + fun merge( existing: String?, players: List, @@ -77,11 +84,14 @@ object DolphinGcpadConfig { fun mergeCore(existing: String?, players: List): String { val siDevices = players - .filter { it.hasController && !it.hasPro && it.player.index in 1..4 } + .filter { it.hasController && it.isSetUpByApp && it.player.index in 1..4 } .associate { "SIDevice${it.player.index - 1}" to STANDARD_CONTROLLER } return IniEditor.setKeys(existing, "[Core]", siDevices) } + // A Pro Controller's section is left to the player. + private val PlayerState.isSetUpByApp: Boolean get() = !hasPro || hasGameCube + // A player whose pad isn't enumerated yet is skipped: docs/virtual-gamepad.md#device-identity private fun sections( players: List, @@ -103,15 +113,11 @@ object DolphinGcpadConfig { deviceId: Int, mappingFor: (PlayerBody) -> Map, ): String? { - val side = when { - player.hasPro -> return null - player.hasFullController -> JoyconSide.DUAL - player.right != null -> JoyconSide.RIGHT - player.left != null -> JoyconSide.LEFT - else -> return null - } + if (!player.isSetUpByApp) return null + val side = player.joyconSide() ?: return null val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" - return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side)))) + val analogTriggers = if (player.hasAnalogTriggers) ANALOG_TRIGGER_LINES else emptyList() + return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side))) + analogTriggers) .joinToString("\n", postfix = "\n") } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index 5f7a7502..8f6f8432 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.toSourceMap @@ -82,7 +83,7 @@ object EdenGamepadConfig { if (index !in 1..4) return@forEach val gamepad = gamepads[index] ?: return@forEach val type = EdenControls.npadType(player) ?: return@forEach - val side = sideFor(player) ?: return@forEach + val side = player.joyconSide() ?: return@forEach val layout = layoutFor(side, mappingFor(PlayerBody(player.player, side))) val p = index - 1 val device = "engine:android,port:${gamepad.port},guid:${gamepad.guid},pad:0" @@ -107,13 +108,6 @@ object EdenGamepadConfig { return keys } - private fun sideFor(player: PlayerState): JoyconSide? = when { - player.hasPro || player.hasFullController -> JoyconSide.DUAL - player.left != null && player.right == null -> JoyconSide.LEFT - player.right != null && player.left == null -> JoyconSide.RIGHT - else -> null - } - private fun layoutFor(side: JoyconSide, mapping: Map): Layout { val buttons = mapping.toSourceMap().mapNotNull { (target, sources) -> inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt index d78a689a..1cf983c2 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt @@ -5,6 +5,7 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -107,6 +108,47 @@ class DolphinGcpadConfigTest { assertFalse(result.contains("[GCPad1]")) } + @Test + fun `a gamecube controller is configured with both sticks and Z on its Z button`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertTrue(result.contains("[GCPad1]")) + assertTrue(result.contains("Buttons/Z = `Button R1`")) // the bumper Z travels on + assertTrue(result.contains("Triggers/R = `Button R3`")) + assertTrue(result.contains("C-Stick/Up = `Axis 14-`")) + } + + @Test + fun `a gamecube controller's analog triggers bind to the brake and gas axes`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertTrue(result.contains("Triggers/L-Analog = `Axis 23+`")) + assertTrue(result.contains("Triggers/R-Analog = `Axis 22+`")) + assertTrue(result.contains("Triggers/L = `Button L3`")) // the click at the second stop + } + + @Test + fun `controllers with digital triggers bind no analog axis`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)))) + + assertFalse(result.contains("L-Analog")) + } + + @Test + fun `core config includes a gamecube controller's port but not a pro controller's`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val players = listOf( + PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)), + PlayerState(PlayerNumber.P2, left = gameCube, right = gameCube), + ) + val result = DolphinGcpadConfig.mergeCore(null, players) + + assertFalse(result.contains("SIDevice0")) + assertTrue(result.contains("SIDevice1 = 6")) + } + @Test fun `core config sets each configured port to a standard controller, preserving other keys`() { val existing = "[Core]\nGFXBackend = Vulkan\nSIDevice0 = 0\n[Interface]\nFoo = Bar\n" diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index 1a0e6e7c..1a1b36c9 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -5,6 +5,7 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -187,6 +188,21 @@ class EdenGamepadConfigTest { assertTrue(result.contains("foo=bar")) } + @Test + fun `a gamecube controller's ZL and Z bind to the bumpers and its trigger clicks to the stick clicks`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val players = listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube)) + + val result = merge(null, players, mapOf(1 to 0)) + + assertTrue(result.contains("player_0_button_zl=\"$DEVICE,button:102,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_L1 + assertTrue(result.contains("player_0_button_zr=\"$DEVICE,button:103,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_R1 + assertTrue(result.contains("player_0_button_l=\"$DEVICE,button:106,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBL + assertTrue(result.contains("player_0_button_r=\"$DEVICE,button:107,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBR + assertFalse(result.contains("player_0_button_lstick")) + assertFalse(result.contains("player_0_button_rstick")) + } + private companion object { // The ids UhidRelay creates our virtual gamepads with. const val VENDOR_ID = 0x1234