From 98c1176cdf43523b81c00b4ad50397d3de2c0f4a Mon Sep 17 00:00:00 2001 From: Ahmed Harmouche Date: Mon, 14 Sep 2026 11:37:56 +0200 Subject: [PATCH] [android] Add sport kick-down automation --- .../softwiredtech/dashpilot/MainActivity.kt | 10 +++ .../dashpilot/ble/VehicleControl.kt | 12 ++++ .../datamodel/dash/DashboardPrefs.kt | 22 ++++++ .../dashpilot/ui/AutomationsScreen.kt | 72 +++++++++++++++---- .../viewmodel/ConnectionViewModel.kt | 38 ++++++++++ 5 files changed, 139 insertions(+), 15 deletions(-) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt index be192bc1..c878d121 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/MainActivity.kt @@ -164,6 +164,8 @@ class MainActivity : ComponentActivity() { val wiperOffAutomation by connectionVM.wiperOffAutomation.collectAsState() val climateKeepAutomation by connectionVM.climateKeepAutomation.collectAsState() val climateKeepMinutes by connectionVM.climateKeepMinutes.collectAsState() + val sportKickdownAutomation by connectionVM.sportKickdownAutomation.collectAsState() + val sportKickdownPercent by connectionVM.sportKickdownPercent.collectAsState() val fingerActions by connectionVM.fingerActions.collectAsState() val navBackStackEntry by navController.currentBackStackEntryAsState() val onDashboard = navBackStackEntry?.destination?.route @@ -305,6 +307,14 @@ class MainActivity : ComponentActivity() { onClimateKeepMinutesChange = { connectionVM.updateClimateKeepMinutes(context, it) }, + sportKickdownEnabled = sportKickdownAutomation, + onSportKickdownChange = { + connectionVM.updateSportKickdownAutomation(context, it) + }, + sportKickdownPercent = sportKickdownPercent, + onSportKickdownPercentChange = { + connectionVM.updateSportKickdownPercent(context, it) + }, fingerActions = fingerActions, onSetFingerAction = { fingers, id -> connectionVM.setFingerAction(context, fingers, id) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt index 494671b4..52a22c81 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ble/VehicleControl.kt @@ -86,6 +86,12 @@ object VehicleControl { // car is in reverse; the override drops when it leaves reverse. Value ignored. const val CMD_MIRROR_DIP_TOGGLE: Int = 0x48 + // --- Sport kick-down (UI_powertrainControl 0x334) --- + // 1=enable, 0=disable. Persisted in NVS by the firmware. + const val CMD_SPORT_KICKDOWN_ENABLE: Int = 0x49 + // Trigger pedal percent, clamped to 10..95. Persisted in NVS by the firmware. + const val CMD_SPORT_KICKDOWN_THRESHOLD: Int = 0x4A + /** Bind (or clear, with actionValue 0) an N-finger tap to a control action. */ fun sendFingerAction(manager: DashKitBleManager, fingers: Int, actionValue: Int): Boolean = send(manager, CMD_MULTI_FINGER_ACTION, (fingers shl 8) or (actionValue and 0xFF)) @@ -102,6 +108,12 @@ object VehicleControl { fun sendClimateKeepDuration(manager: DashKitBleManager, minutes: Int): Boolean = send(manager, CMD_CLIMATE_KEEP_DURATION, minutes) + fun sendSportKickdown(manager: DashKitBleManager, enabled: Boolean): Boolean = + send(manager, CMD_SPORT_KICKDOWN_ENABLE, if (enabled) 1 else 0) + + fun sendSportKickdownThreshold(manager: DashKitBleManager, percent: Int): Boolean = + send(manager, CMD_SPORT_KICKDOWN_THRESHOLD, percent) + /** Ask the DashKit to open a pairing window for one new device. */ fun sendEnterPairing(manager: DashKitBleManager): Boolean { val ok = send(manager, CMD_ENTER_PAIRING, 1) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt index df9bb96f..d5126f81 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardPrefs.kt @@ -24,6 +24,8 @@ const val PREF_PINNED_CONTROL = "pinned_control" const val PREF_WIPER_OFF_AUTOMATION = "wiper_off_automation" const val PREF_CLIMATE_KEEP_AUTOMATION = "climate_keep_automation" const val PREF_CLIMATE_KEEP_MINUTES = "climate_keep_minutes" +const val PREF_SPORT_KICKDOWN_AUTOMATION = "sport_kickdown_automation" +const val PREF_SPORT_KICKDOWN_PERCENT = "sport_kickdown_percent" // Map of finger count (3..5) -> control id, serialized as "3=glovebox;4=frunk". const val PREF_FINGER_ACTIONS = "finger_actions" // Legacy single three-finger binding, migrated into PREF_FINGER_ACTIONS. @@ -31,6 +33,8 @@ const val PREF_THREE_FINGER_ACTION = "three_finger_action" const val DEFAULT_WIPER_OFF_AUTOMATION = false const val DEFAULT_CLIMATE_KEEP_AUTOMATION = false const val DEFAULT_CLIMATE_KEEP_MINUTES = 5 +const val DEFAULT_SPORT_KICKDOWN_AUTOMATION = false +const val DEFAULT_SPORT_KICKDOWN_PERCENT = 80 // Display settings defaults const val DEFAULT_SHOW_PHONE_BATTERY = true @@ -133,6 +137,24 @@ fun setClimateKeepMinutes(context: Context, value: Int) { .edit { putInt(PREF_CLIMATE_KEEP_MINUTES, value) } } +fun getSportKickdownAutomation(context: Context): Boolean = + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(PREF_SPORT_KICKDOWN_AUTOMATION, DEFAULT_SPORT_KICKDOWN_AUTOMATION) + +fun setSportKickdownAutomation(context: Context, value: Boolean) { + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .edit { putBoolean(PREF_SPORT_KICKDOWN_AUTOMATION, value) } +} + +fun getSportKickdownPercent(context: Context): Int = + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .getInt(PREF_SPORT_KICKDOWN_PERCENT, DEFAULT_SPORT_KICKDOWN_PERCENT) + +fun setSportKickdownPercent(context: Context, value: Int) { + context.getSharedPreferences(DASH_PREFS_NAME, Context.MODE_PRIVATE) + .edit { putInt(PREF_SPORT_KICKDOWN_PERCENT, value) } +} + /** * Returns the bindings of finger count (3..5) -> control id. Performs a one-time * migration of the legacy single three-finger binding into the new map. diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt index 549fc7d5..3b0c8fba 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/AutomationsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.AcUnit import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.WaterDrop import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu @@ -59,6 +60,10 @@ private val FINGER_COUNTS = 3..5 // Minutes the keep-climate-on window can run (matches the firmware clamp). private val CLIMATE_KEEP_MINUTE_RANGE = 1..60 +// Matches the firmware clamp. +private val SPORT_KICKDOWN_PERCENT_RANGE = 10..95 +private const val SPORT_KICKDOWN_PERCENT_STEP = 5 + /** * Automations screen. Lets the user enable the wiper-off automation and bind * 3-, 4-, and 5-finger infotainment taps each to a vehicle control. Bindings are @@ -74,6 +79,10 @@ fun AutomationsScreen( onClimateKeepChange: (Boolean) -> Unit, climateKeepMinutes: Int, onClimateKeepMinutesChange: (Int) -> Unit, + sportKickdownEnabled: Boolean, + onSportKickdownChange: (Boolean) -> Unit, + sportKickdownPercent: Int, + onSportKickdownPercentChange: (Int) -> Unit, fingerActions: Map, onSetFingerAction: (fingers: Int, id: String?) -> Unit, onChangeFingerCount: (from: Int, to: Int) -> Unit, @@ -118,9 +127,36 @@ fun AutomationsScreen( onToggle = { onClimateKeepChange(!climateKeepEnabled) }, extraContent = if (climateKeepEnabled) { { - ClimateKeepDurationFooter( - minutes = climateKeepMinutes, - onMinutesChange = onClimateKeepMinutesChange + NumberPickerFooter( + label = "Stop after", + value = climateKeepMinutes, + unit = "min", + range = CLIMATE_KEEP_MINUTE_RANGE, + onValueChange = onClimateKeepMinutesChange + ) + } + } else null + ) + + Spacer(modifier = Modifier.height(28.dp)) + + SectionLabel("Driving") + Spacer(modifier = Modifier.height(8.dp)) + AutomationRow( + icon = Icons.Rounded.Speed, + title = "Sport kick-down", + subtitle = "Switch from Chill to Sport pedal response while the accelerator is pressed past the threshold. Reverts when you ease off.", + checked = sportKickdownEnabled, + onToggle = { onSportKickdownChange(!sportKickdownEnabled) }, + extraContent = if (sportKickdownEnabled) { + { + NumberPickerFooter( + label = "Pedal threshold", + value = sportKickdownPercent, + unit = "%", + range = SPORT_KICKDOWN_PERCENT_RANGE, + step = SPORT_KICKDOWN_PERCENT_STEP, + onValueChange = onSportKickdownPercentChange ) } } else null @@ -175,11 +211,16 @@ private fun SectionLabel(text: String) { } @Composable -private fun ClimateKeepDurationFooter( - minutes: Int, - onMinutesChange: (Int) -> Unit +private fun NumberPickerFooter( + label: String, + value: Int, + unit: String, + range: IntRange, + onValueChange: (Int) -> Unit, + step: Int = 1 ) { var showPicker by remember { mutableStateOf(false) } + val choices = remember(range, step) { (range step step).toList() } Spacer(modifier = Modifier.height(10.dp)) Row( @@ -187,7 +228,7 @@ private fun ClimateKeepDurationFooter( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Stop after", + text = label, color = DarkColors.TextMuted, fontSize = 14.sp, modifier = Modifier.weight(1f) @@ -200,7 +241,7 @@ private fun ClimateKeepDurationFooter( .padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - Text(text = "$minutes min", color = Color.White, fontSize = 15.sp) + Text(text = "$value $unit", color = Color.White, fontSize = 15.sp) Icon( imageVector = Icons.Rounded.ArrowDropDown, contentDescription = null, @@ -211,19 +252,20 @@ private fun ClimateKeepDurationFooter( } if (showPicker) { - var pending by remember { mutableIntStateOf(minutes) } + var pending by remember { mutableIntStateOf(value) } AlertDialog( onDismissRequest = { showPicker = false }, - title = { Text("Stop after") }, + title = { Text(label) }, text = { AndroidView( factory = { ctx -> NumberPicker(ctx).apply { - minValue = CLIMATE_KEEP_MINUTE_RANGE.first - maxValue = CLIMATE_KEEP_MINUTE_RANGE.last + minValue = 0 + maxValue = choices.lastIndex + displayedValues = choices.map { "$it $unit" }.toTypedArray() wrapSelectorWheel = false - value = minutes - setOnValueChangedListener { _, _, value -> pending = value } + this.value = choices.indexOf(value).coerceAtLeast(0) + setOnValueChangedListener { _, _, idx -> pending = choices[idx] } } }, modifier = Modifier.fillMaxWidth() @@ -232,7 +274,7 @@ private fun ClimateKeepDurationFooter( confirmButton = { TextButton(onClick = { showPicker = false - onMinutesChange(pending) + onValueChange(pending) }) { Text("OK") } }, dismissButton = { diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt index 61cd6257..233d7339 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/viewmodel/ConnectionViewModel.kt @@ -47,6 +47,11 @@ import com.softwiredtech.dashpilot.datamodel.dash.getClimateKeepAutomation import com.softwiredtech.dashpilot.datamodel.dash.setClimateKeepAutomation import com.softwiredtech.dashpilot.datamodel.dash.getClimateKeepMinutes import com.softwiredtech.dashpilot.datamodel.dash.setClimateKeepMinutes +import com.softwiredtech.dashpilot.datamodel.dash.DEFAULT_SPORT_KICKDOWN_PERCENT +import com.softwiredtech.dashpilot.datamodel.dash.getSportKickdownAutomation +import com.softwiredtech.dashpilot.datamodel.dash.setSportKickdownAutomation +import com.softwiredtech.dashpilot.datamodel.dash.getSportKickdownPercent +import com.softwiredtech.dashpilot.datamodel.dash.setSportKickdownPercent import com.softwiredtech.dashpilot.ui.controls.controlById import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.datasource.CommaDataSource @@ -119,6 +124,12 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { private val _climateKeepMinutes = MutableStateFlow(DEFAULT_CLIMATE_KEEP_MINUTES) val climateKeepMinutes = _climateKeepMinutes.asStateFlow() + private val _sportKickdownAutomation = MutableStateFlow(false) + val sportKickdownAutomation = _sportKickdownAutomation.asStateFlow() + + private val _sportKickdownPercent = MutableStateFlow(DEFAULT_SPORT_KICKDOWN_PERCENT) + val sportKickdownPercent = _sportKickdownPercent.asStateFlow() + private val _fingerActions = MutableStateFlow>(emptyMap()) val fingerActions = _fingerActions.asStateFlow() @@ -126,6 +137,8 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { _wiperOffAutomation.value = getWiperOffAutomation(context) _climateKeepAutomation.value = getClimateKeepAutomation(context) _climateKeepMinutes.value = getClimateKeepMinutes(context) + _sportKickdownAutomation.value = getSportKickdownAutomation(context) + _sportKickdownPercent.value = getSportKickdownPercent(context) _fingerActions.value = getFingerActions(context) } @@ -158,6 +171,24 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { } } + fun updateSportKickdownAutomation(context: Context, value: Boolean) { + setSportKickdownAutomation(context, value) + _sportKickdownAutomation.value = value + _bleManager.value?.let { VehicleControl.sendSportKickdown(it, value) } + } + + private var sportKickdownPercentPush: Job? = null + + fun updateSportKickdownPercent(context: Context, value: Int) { + setSportKickdownPercent(context, value) + _sportKickdownPercent.value = value + sportKickdownPercentPush?.cancel() + sportKickdownPercentPush = viewModelScope.launch { + delay(400) + _bleManager.value?.let { VehicleControl.sendSportKickdownThreshold(it, value) } + } + } + fun setFingerAction(context: Context, fingers: Int, id: String?) { val next = _fingerActions.value.toMutableMap() if (id.isNullOrBlank()) next.remove(fingers) else next[fingers] = id @@ -435,6 +466,13 @@ class ConnectionViewModel(private var networkUtil: NetworkUtil) : ViewModel() { val climateKeepMin = getClimateKeepMinutes(context) _climateKeepMinutes.value = climateKeepMin VehicleControl.sendClimateKeepDuration(mgr, climateKeepMin) + + val sportKickdown = getSportKickdownAutomation(context) + _sportKickdownAutomation.value = sportKickdown + VehicleControl.sendSportKickdown(mgr, sportKickdown) + val sportKickdownPct = getSportKickdownPercent(context) + _sportKickdownPercent.value = sportKickdownPct + VehicleControl.sendSportKickdownThreshold(mgr, sportKickdownPct) } } }