diff --git a/bridge/bridge_ios.cpp b/bridge/bridge_ios.cpp index 0974b405..7af8cbea 100644 --- a/bridge/bridge_ios.cpp +++ b/bridge/bridge_ios.cpp @@ -34,6 +34,12 @@ static void carStateToBridge(const CarState& cs, BridgeCarState* out) { out->packTMax = cs.packTMax; out->odometer = cs.odometer; out->acTemp = cs.acTemp; + out->acTempRight = cs.acTempRight; + out->hvacFanLevel = cs.hvacFanLevel; + out->hvacPowerState = cs.hvacPowerState; + out->hvacAcMode = cs.hvacAcMode; + out->hvacRecirc = cs.hvacRecirc; + out->hvacKeepClimateOn = cs.hvacKeepClimateOn; out->madsActive = cs.madsActive; out->selfdriveActive = cs.selfdriveActive; out->experimentalMode = cs.experimentalMode; diff --git a/bridge/bridge_ios.h b/bridge/bridge_ios.h index 27a42832..b135e86a 100644 --- a/bridge/bridge_ios.h +++ b/bridge/bridge_ios.h @@ -39,6 +39,12 @@ typedef struct { double packTMax; double odometer; double acTemp; + double acTempRight; + double hvacFanLevel; + double hvacPowerState; + double hvacAcMode; + double hvacRecirc; + double hvacKeepClimateOn; // Openpilot state double madsActive; diff --git a/bridge/car/car_state.h b/bridge/car/car_state.h index 693a7263..bb4b0c37 100644 --- a/bridge/car/car_state.h +++ b/bridge/car/car_state.h @@ -34,6 +34,12 @@ struct CarState { double packTMax = 0; double odometer = 0; double acTemp = 0; + double acTempRight = 0; + double hvacFanLevel = 0; + double hvacPowerState = 0; + double hvacAcMode = 0; + double hvacRecirc = 0; + double hvacKeepClimateOn = 0; // Openpilot state double madsActive = 0; @@ -46,7 +52,8 @@ struct CarState { char vin[VIN_LENGTH + 1] = {}; static constexpr size_t VIN_DOUBLE_COUNT = 3; - static constexpr size_t FIELD_COUNT = 31 + VIN_DOUBLE_COUNT; + static constexpr size_t VIN_OFFSET = 37; + static constexpr size_t FIELD_COUNT = VIN_OFFSET + VIN_DOUBLE_COUNT; void toArray(double* out) const { // Party bus @@ -86,11 +93,17 @@ struct CarState { out[29] = changingLane; out[30] = acTemp; + out[31] = acTempRight; + out[32] = hvacFanLevel; + out[33] = hvacPowerState; + out[34] = hvacAcMode; + out[35] = hvacRecirc; + out[36] = hvacKeepClimateOn; // VIN chars ride the double array as raw bit patterns, 8 bytes per // double; decoded by CanFrameDecoder.arrayToCarState. char padded[VIN_DOUBLE_COUNT * sizeof(double)] = {}; std::memcpy(padded, vin, VIN_LENGTH); - std::memcpy(out + 31, padded, sizeof(padded)); + std::memcpy(out + VIN_OFFSET, padded, sizeof(padded)); } }; diff --git a/bridge/car/cars/tesla.h b/bridge/car/cars/tesla.h index 274808f4..c5cef309 100644 --- a/bridge/car/cars/tesla.h +++ b/bridge/car/cars/tesla.h @@ -162,6 +162,12 @@ class TeslaDashKitMapper : public CarStateMapper { // Climate setpoint temperature (degC) from the vehicle bus. cs.acTemp = cp.get(1, "UI_hvacRequest", "UI_hvacReqTempSetpointLeft"); + cs.acTempRight = cp.get(1, "UI_hvacRequest", "UI_hvacReqTempSetpointRight"); + cs.hvacFanLevel = cp.get(1, "UI_hvacRequest", "UI_hvacReqBlowerSegment"); + cs.hvacPowerState = cp.get(1, "UI_hvacRequest", "UI_hvacReqUserPowerState"); + cs.hvacAcMode = cp.get(1, "UI_hvacRequest", "UI_hvacReqACDisable"); + cs.hvacRecirc = cp.get(1, "UI_hvacRequest", "UI_hvacReqRecirc"); + cs.hvacKeepClimateOn = cp.get(1, "UI_hvacRequest", "UI_hvacReqKeepClimateOn"); updateVehicleBus(cp, cs); vin_.update(cp, cs); 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 c878d121..6dec6618 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 @@ -44,6 +44,8 @@ import com.softwiredtech.dashpilot.datamodel.dash.setLoadedManifests import com.softwiredtech.dashpilot.datasource.ConnectionStatus import com.softwiredtech.dashpilot.datasource.DataSourceType import com.softwiredtech.dashpilot.navigation.AutomationsRoute +import com.softwiredtech.dashpilot.navigation.BatteryRoute +import com.softwiredtech.dashpilot.navigation.ClimateRoute import com.softwiredtech.dashpilot.navigation.ControlsRoute import com.softwiredtech.dashpilot.navigation.DashboardRoute import com.softwiredtech.dashpilot.navigation.OnboardingRoute @@ -52,6 +54,8 @@ import com.softwiredtech.dashpilot.navigation.SetupRoute import com.softwiredtech.dashpilot.navigation.TeslaEnrollRoute import com.softwiredtech.dashpilot.navigation.ThemePickerRoute import com.softwiredtech.dashpilot.ui.AutomationsScreen +import com.softwiredtech.dashpilot.ui.BatteryScreen +import com.softwiredtech.dashpilot.ui.ClimateScreen import com.softwiredtech.dashpilot.ui.ControlScreen import com.softwiredtech.dashpilot.ui.DashboardScreen import com.softwiredtech.dashpilot.ui.HomeScreen @@ -272,6 +276,12 @@ class MainActivity : ComponentActivity() { connectionVM.disconnect() }, onNext = toDashboard, + onBattery = { + navController.navigate(BatteryRoute) + }, + onClimate = { + navController.navigate(ClimateRoute) + }, onAutomations = { navController.navigate(AutomationsRoute) }, @@ -284,6 +294,30 @@ class MainActivity : ComponentActivity() { } ) } + composable { + val manager by connectionVM.bleManager.collectAsState() + val dashStateFlow by connectionVM.dashState.collectAsState() + BatteryScreen( + dashState = dashStateFlow, + bleManager = manager, + onBack = { navController.popBackStack() } + ) + } + composable { + val dashStateFlow by connectionVM.dashState.collectAsState() + ClimateScreen( + dashState = dashStateFlow, + climateKeepEnabled = climateKeepAutomation, + onClimateKeepChange = { + connectionVM.updateClimateKeepAutomation(context, it) + }, + climateKeepMinutes = climateKeepMinutes, + onClimateKeepMinutesChange = { + connectionVM.updateClimateKeepMinutes(context, it) + }, + onBack = { navController.popBackStack() } + ) + } composable { val manager by connectionVM.bleManager.collectAsState() ControlScreen( diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/CarState.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/CarState.kt index 7dae0acc..838bee52 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/CarState.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/CarState.kt @@ -31,6 +31,12 @@ data class CarState( val packTMax: Float = 0f, val odometer: Float = 0f, val acTemp: Float = 0f, + val acTempRight: Float = 0f, + val hvacFanLevel: Float = 0f, + val hvacPowerState: Float = 0f, + val hvacAcMode: Float = 0f, + val hvacRecirc: Float = 0f, + val hvacKeepClimateOn: Float = 0f, // Openpilot val selfdriveActive: Boolean = false, val experimentalMode: Boolean = false, @@ -46,11 +52,12 @@ data class CarState( packTMin = if (packTMin != 0f) packTMin * 1.8f + 32f else 0f, packTMax = if (packTMax != 0f) packTMax * 1.8f + 32f else 0f, acTemp = if (acTemp != 0f) acTemp * 1.8f + 32f else 0f, + acTempRight = if (acTempRight != 0f) acTempRight * 1.8f + 32f else 0f, fusedSpeedLimit = if (speedLimitSignsInKm()) fusedSpeedLimit * KM_TO_MILES else fusedSpeedLimit, ) companion object { - const val FIELD_COUNT = 34 + const val FIELD_COUNT = 40 private const val KM_TO_MILES = 0.621371f private val MILES_COUNTRIES = setOf("US", "GB", "MM", "LR") diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt index 81b0e1a7..1166d69d 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/navigation/Routes.kt @@ -20,6 +20,12 @@ object ThemePickerRoute @Serializable object AutomationsRoute +@Serializable +object BatteryRoute + +@Serializable +object ClimateRoute + @Serializable object ControlsRoute 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 3b0c8fba..584667f4 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 @@ -58,7 +58,7 @@ import com.softwiredtech.dashpilot.ui.theme.DarkColors 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 +internal val CLIMATE_KEEP_MINUTE_RANGE = 1..60 // Matches the firmware clamp. private val SPORT_KICKDOWN_PERCENT_RANGE = 10..95 @@ -211,7 +211,7 @@ private fun SectionLabel(text: String) { } @Composable -private fun NumberPickerFooter( +internal fun NumberPickerFooter( label: String, value: Int, unit: String, @@ -403,7 +403,7 @@ private fun AddTriggerButton(onClick: () -> Unit) { } @Composable -private fun AutomationRow( +internal fun AutomationRow( icon: ImageVector, title: String, subtitle: String?, diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/BatteryScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/BatteryScreen.kt new file mode 100644 index 00000000..2f4ee059 --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/BatteryScreen.kt @@ -0,0 +1,253 @@ +package com.softwiredtech.dashpilot.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BatteryChargingFull +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.datamodel.dash.CarState +import com.softwiredtech.dashpilot.datamodel.dash.DashState +import com.softwiredtech.dashpilot.datasource.DashKitBleManager +import com.softwiredtech.dashpilot.ui.controls.ControlActionButton +import com.softwiredtech.dashpilot.ui.controls.controlById +import com.softwiredtech.dashpilot.ui.theme.AccentColor +import com.softwiredtech.dashpilot.ui.theme.DarkColors +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlin.math.roundToInt + +@Composable +fun BatteryScreen( + dashState: Flow?, + bleManager: DashKitBleManager?, + onBack: () -> Unit +) { + val fallback = DashState() + val state by (dashState ?: flowOf(fallback)).collectAsState(initial = fallback) + val car = state.carState + + Box( + modifier = Modifier + .fillMaxSize() + .background(DarkColors.Background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + ScreenHeader(title = "Battery", onBack = onBack) + + Spacer(modifier = Modifier.height(24.dp)) + + SocCard(car) + controlById("battery_preheat")?.let { action -> + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = "Preheat", + color = DarkColors.TextMuted, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + ControlActionButton( + action = action, + pinned = false, + enabled = bleManager != null, + onClick = { action.perform(bleManager) } + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + InfoSection( + "Energy", + listOf( + "Remaining" to energyText(car.nominalEnergyRemaining), + "Usable" to usableEnergyText(car), + "Full Pack" to energyText(car.fullPackEnergy), + "Buffer" to bufferText(car) + ) + ) + + Spacer(modifier = Modifier.height(28.dp)) + + InfoSection( + "Power", + listOf( + "Power" to powerText(car), + "Max Discharge" to kilowattText(car.maxDischargePower), + "Max Regen" to kilowattText(car.maxRegenPower) + ) + ) + + Spacer(modifier = Modifier.height(28.dp)) + + InfoSection( + "Pack", + listOf( + "Voltage" to voltageText(car), + "Current" to currentText(car), + "Temp Min" to tempText(car.packTMin), + "Temp Max" to tempText(car.packTMax) + ) + ) + + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun SocCard(car: CarState) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = socText(car), + color = Color.White, + fontSize = 44.sp, + fontWeight = FontWeight.Bold + ) + IconChip( + icon = Icons.Rounded.BatteryChargingFull, + tint = AccentColor, + background = AccentColor.copy(alpha = 0.14f) + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(DarkColors.SurfaceSelected) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(socFraction(car)) + .background(AccentColor) + ) + } + Text( + text = "State of charge", + color = DarkColors.TextMuted, + fontSize = 13.sp + ) + } +} + +@Composable +private fun InfoSection(title: String, rows: List>) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + color = DarkColors.TextMuted, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + Column( + modifier = Modifier + .fillMaxWidth() + .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + ) { + rows.forEachIndexed { index, (label, value) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, color = DarkColors.TextMuted, fontSize = 15.sp) + Text( + text = value, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + if (index < rows.lastIndex) { + HorizontalDivider( + color = DarkColors.Border, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } +} + +private fun hasSoc(car: CarState): Boolean = + car.fullPackEnergy > 0f && car.nominalEnergyRemaining > 0f && car.fullPackEnergy - car.energyBuffer > 0f + +private fun socFraction(car: CarState): Float { + if (!hasSoc(car)) return 0f + val soc = (car.nominalEnergyRemaining - car.energyBuffer) / (car.fullPackEnergy - car.energyBuffer) + return soc.coerceIn(0f, 1f) +} + +private fun socText(car: CarState): String = + if (hasSoc(car)) "${(socFraction(car) * 100f).roundToInt()}%" else "—" + +private fun energyText(value: Float): String = + if (value > 0f) "%.1f kWh".format(value) else "—" + +private fun usableEnergyText(car: CarState): String = + if (car.nominalEnergyRemaining > 0f) { + "%.1f kWh".format((car.nominalEnergyRemaining - car.energyBuffer).coerceAtLeast(0f)) + } else "—" + +private fun bufferText(car: CarState): String = + if (car.fullPackEnergy > 0f) "%.1f kWh".format(car.energyBuffer) else "—" + +private fun kilowattText(value: Float): String = + if (value > 0f) "%.0f kW".format(value) else "—" + +private fun powerText(car: CarState): String = + if (car.packVoltage > 0f) "%.1f kW".format(car.packVoltage * car.packCurrent / 1000f) else "—" + +private fun voltageText(car: CarState): String = + if (car.packVoltage > 0f) "%.1f V".format(car.packVoltage) else "—" + +private fun currentText(car: CarState): String = + if (car.packVoltage > 0f) "%.1f A".format(car.packCurrent) else "—" + +private fun tempText(value: Float): String = + if (value != 0f) "${value.roundToInt()}°" else "—" diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ClimateScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ClimateScreen.kt new file mode 100644 index 00000000..fb89174e --- /dev/null +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/ClimateScreen.kt @@ -0,0 +1,231 @@ +package com.softwiredtech.dashpilot.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AcUnit +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.softwiredtech.dashpilot.datamodel.dash.CarState +import com.softwiredtech.dashpilot.datamodel.dash.DashState +import com.softwiredtech.dashpilot.ui.theme.AccentColor +import com.softwiredtech.dashpilot.ui.theme.DarkColors +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlin.math.roundToInt + +@Composable +fun ClimateScreen( + dashState: Flow?, + climateKeepEnabled: Boolean, + onClimateKeepChange: (Boolean) -> Unit, + climateKeepMinutes: Int, + onClimateKeepMinutesChange: (Int) -> Unit, + onBack: () -> Unit +) { + val fallback = DashState() + val state by (dashState ?: flowOf(fallback)).collectAsState(initial = fallback) + val car = state.carState + val imperial = state.displaySettings.useImperial + // A real setpoint is never 0, so 0 means no UI_hvacRequest seen yet. + val live = car.acTemp != 0f + + Box( + modifier = Modifier + .fillMaxSize() + .background(DarkColors.Background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + ScreenHeader(title = "Climate", onBack = onBack) + + Spacer(modifier = Modifier.height(24.dp)) + + SetpointCard(car, imperial) + + Spacer(modifier = Modifier.height(28.dp)) + + SectionTitle("Automation") + Spacer(modifier = Modifier.height(8.dp)) + AutomationRow( + icon = Icons.Rounded.AcUnit, + title = "Keep climate on", + subtitle = "Keep the climate on when you leave the car. The automation stops after the set time, or when you return to the car.", + checked = climateKeepEnabled, + onToggle = { onClimateKeepChange(!climateKeepEnabled) }, + extraContent = if (climateKeepEnabled) { + { + NumberPickerFooter( + label = "Stop after", + value = climateKeepMinutes, + unit = "min", + range = CLIMATE_KEEP_MINUTE_RANGE, + onValueChange = onClimateKeepMinutesChange + ) + } + } else null + ) + + Spacer(modifier = Modifier.height(28.dp)) + + InfoSection( + "Setpoints", + listOf( + "Driver" to setpointText(car.acTemp, imperial), + "Passenger" to setpointText(car.acTempRight, imperial) + ) + ) + + Spacer(modifier = Modifier.height(28.dp)) + + InfoSection( + "Settings", + listOf( + "Power" to enumText(live, car.hvacPowerState, POWER_LABELS), + "Fan" to fanText(live, car.hvacFanLevel), + "A/C" to enumText(live, car.hvacAcMode, AC_LABELS), + "Recirculation" to enumText(live, car.hvacRecirc, RECIRC_LABELS), + "Keep climate" to enumText(live, car.hvacKeepClimateOn, KEEP_LABELS) + ) + ) + + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun SetpointCard(car: CarState, imperial: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = setpointText(car.acTemp, imperial), + color = Color.White, + fontSize = 44.sp, + fontWeight = FontWeight.Bold + ) + IconChip( + icon = Icons.Rounded.AcUnit, + tint = AccentColor, + background = AccentColor.copy(alpha = 0.14f) + ) + } + Text( + text = "Driver setpoint", + color = DarkColors.TextMuted, + fontSize = 13.sp + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + color = DarkColors.TextMuted, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) +} + +@Composable +private fun InfoSection(title: String, rows: List>) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(title) + Column( + modifier = Modifier + .fillMaxWidth() + .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + ) { + rows.forEachIndexed { index, (label, value) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, color = DarkColors.TextMuted, fontSize = 15.sp) + Text( + text = value, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + if (index < rows.lastIndex) { + HorizontalDivider( + color = DarkColors.Border, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } +} + +private val POWER_LABELS = listOf("Off", "On", "Preconditioning", "Overheat protection (fan)", "Overheat protection") +private val AC_LABELS = listOf("Auto", "Off", "On") +private val RECIRC_LABELS = listOf("Auto", "Recirculate", "Fresh air") +private val KEEP_LABELS = listOf("Off", "Keep", "Dog mode", "Camp mode") + +private fun setpointText(value: Float, imperial: Boolean): String { + if (value == 0f) return "—" + val lo = if (imperial) 59f else 15f + val hi = if (imperial) 82.4f else 28f + return when { + value <= lo -> "LO" + value >= hi -> "HI" + else -> "${value.roundToInt()}°" + } +} + +private fun enumText(live: Boolean, value: Float, labels: List): String = + if (live) labels.getOrNull(value.roundToInt()) ?: "—" else "—" + +private fun fanText(live: Boolean, value: Float): String { + if (!live) return "—" + return when (val level = value.roundToInt()) { + 0 -> "Off" + 11 -> "Auto" + in 1..10 -> "$level" + else -> "—" + } +} diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt index 073878d3..8b6e4f33 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/ui/HomeScreen.kt @@ -87,6 +87,8 @@ fun HomeScreen( onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onNext: () -> Unit, + onBattery: () -> Unit, + onClimate: () -> Unit, onAutomations: () -> Unit, onControls: () -> Unit, onDrive: () -> Unit, @@ -111,6 +113,8 @@ fun HomeScreen( onConnect = onConnect, onDisconnect = onDisconnect, onSelectDataSource = { selectedDataSource = it }, + onBattery = onBattery, + onClimate = onClimate, onAutomations = onAutomations, onControls = onControls, onDrive = onDrive, @@ -146,6 +150,8 @@ private fun ConnectedHomeContent( onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onSelectDataSource: (String) -> Unit, + onBattery: () -> Unit, + onClimate: () -> Unit, onAutomations: () -> Unit, onControls: () -> Unit, onDrive: () -> Unit, @@ -192,6 +198,8 @@ private fun ConnectedHomeContent( tesla = tesla, resetPending = resetPending, onEnrollTesla = onEnrollTesla, + onBattery = onBattery, + onClimate = onClimate, onAutomations = onAutomations, onControls = onControls, onDrive = onDrive @@ -209,6 +217,8 @@ private fun ConnectedHomeContent( onConnect = onConnect, onDisconnect = onDisconnect, onSelectDataSource = onSelectDataSource, + onBattery = onBattery, + onClimate = onClimate, onAutomations = onAutomations, onControls = onControls, onDrive = onDrive @@ -279,12 +289,14 @@ private fun PortraitContent( onConnect: (serverAddress: String, dataSourceType: String) -> Unit, onDisconnect: () -> Unit, onSelectDataSource: (String) -> Unit, + onBattery: () -> Unit, + onClimate: () -> Unit, onAutomations: () -> Unit, onControls: () -> Unit, onDrive: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth()) { - WidgetGrid(car = car, useImperial = useImperial, fillHeight = false) + WidgetGrid(car = car, useImperial = useImperial, fillHeight = false, onBattery = onBattery, onClimate = onClimate) Spacer(modifier = Modifier.height(32.dp)) @@ -339,6 +351,8 @@ private fun LandscapeContent( tesla: TeslaStatus, resetPending: Boolean, onEnrollTesla: () -> Unit, + onBattery: () -> Unit, + onClimate: () -> Unit, onAutomations: () -> Unit, onControls: () -> Unit, onDrive: () -> Unit @@ -354,6 +368,8 @@ private fun LandscapeContent( car = car, useImperial = useImperial, fillHeight = true, + onBattery = onBattery, + onClimate = onClimate, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -411,6 +427,8 @@ private fun WidgetGrid( car: CarState, useImperial: Boolean, fillHeight: Boolean, + onBattery: () -> Unit, + onClimate: () -> Unit, modifier: Modifier = Modifier ) { Column( @@ -427,14 +445,16 @@ private fun WidgetGrid( label = "Battery", value = socText(car), fillHeight = fillHeight, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onClick = onBattery ) InfoWidget( icon = Icons.Rounded.DeviceThermostat, label = "Battery Temp", value = batteryTempText(car), fillHeight = fillHeight, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onClick = onBattery ) } Row( @@ -446,7 +466,8 @@ private fun WidgetGrid( label = "AC Temp", value = tempText(car.acTemp), fillHeight = fillHeight, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onClick = onClimate ) InfoWidget( icon = Icons.Rounded.Speed, @@ -486,12 +507,14 @@ private fun InfoWidget( label: String, value: String, fillHeight: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null ) { Column( modifier = modifier .then(if (fillHeight) Modifier.fillMaxHeight() else Modifier.aspectRatio(1.4f)) .background(DarkColors.Surface, RoundedCornerShape(16.dp)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(16.dp), verticalArrangement = Arrangement.SpaceBetween ) { @@ -541,7 +564,7 @@ private fun ActionTile( } @Composable -private fun IconChip(icon: ImageVector, tint: Color, background: Color) { +internal fun IconChip(icon: ImageVector, tint: Color, background: Color) { Box( modifier = Modifier .size(36.dp) diff --git a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/CanFrameDecoder.kt b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/CanFrameDecoder.kt index bc5d85c2..9417ec63 100644 --- a/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/CanFrameDecoder.kt +++ b/dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/vehicle/CanFrameDecoder.kt @@ -58,7 +58,13 @@ class CanFrameDecoder( madsActive = values[28] > 0, changingLane = values[29] > 0, acTemp = values[30].toFloat(), - vin = decodeVin(values, offset = 31) + acTempRight = values[31].toFloat(), + hvacFanLevel = values[32].toFloat(), + hvacPowerState = values[33].toFloat(), + hvacAcMode = values[34].toFloat(), + hvacRecirc = values[35].toFloat(), + hvacKeepClimateOn = values[36].toFloat(), + vin = decodeVin(values, offset = 37) ) } diff --git a/dashpilot-android/bridge/tests/tesla_vin_mapper_test.cpp b/dashpilot-android/bridge/tests/tesla_vin_mapper_test.cpp index d2226e94..bcc28db8 100644 --- a/dashpilot-android/bridge/tests/tesla_vin_mapper_test.cpp +++ b/dashpilot-android/bridge/tests/tesla_vin_mapper_test.cpp @@ -132,7 +132,7 @@ void vin_round_trips_through_the_double_array() { double out[CarState::FIELD_COUNT]; h.cs.toArray(out); char decoded[CarState::VIN_DOUBLE_COUNT * sizeof(double) + 1] = {}; - std::memcpy(decoded, out + 31, CarState::VIN_DOUBLE_COUNT * sizeof(double)); + std::memcpy(decoded, out + CarState::VIN_OFFSET, CarState::VIN_DOUBLE_COUNT * sizeof(double)); CHECK(std::string(decoded) == kVin); } @@ -140,7 +140,7 @@ void empty_vin_encodes_as_zero_doubles() { CarState cs; double out[CarState::FIELD_COUNT]; cs.toArray(out); - for (size_t i = 31; i < CarState::FIELD_COUNT; i++) { + for (size_t i = CarState::VIN_OFFSET; i < CarState::FIELD_COUNT; i++) { CHECK(out[i] == 0.0); } } diff --git a/dashpilot-ios/dashpilot/DataSource/DemoDataSource.swift b/dashpilot-ios/dashpilot/DataSource/DemoDataSource.swift index 3d57d07a..35ee2c92 100644 --- a/dashpilot-ios/dashpilot/DataSource/DemoDataSource.swift +++ b/dashpilot-ios/dashpilot/DataSource/DemoDataSource.swift @@ -157,6 +157,9 @@ final class DemoDataSource: IDataSource { s.packTMin = 22 s.packTMax = 28 s.acTemp = 22 + s.acTempRight = 21 + s.hvacFanLevel = 11 + s.hvacPowerState = 1 s.odometer = Float(42000 + t * 0.01) return s diff --git a/dashpilot-ios/dashpilot/Models/CarState.swift b/dashpilot-ios/dashpilot/Models/CarState.swift index d8ebb08d..3a019af6 100644 --- a/dashpilot-ios/dashpilot/Models/CarState.swift +++ b/dashpilot-ios/dashpilot/Models/CarState.swift @@ -31,6 +31,12 @@ struct CarState: Codable { var packTMax: Float = 0 var odometer: Float = 0 var acTemp: Float = 0 + var acTempRight: Float = 0 + var hvacFanLevel: Float = 0 + var hvacPowerState: Float = 0 + var hvacAcMode: Float = 0 + var hvacRecirc: Float = 0 + var hvacKeepClimateOn: Float = 0 // Openpilot state var madsActive: Bool = false @@ -68,6 +74,12 @@ struct CarState: Codable { packTMax = Float(bridge.packTMax) odometer = Float(bridge.odometer) acTemp = Float(bridge.acTemp) + acTempRight = Float(bridge.acTempRight) + hvacFanLevel = Float(bridge.hvacFanLevel) + hvacPowerState = Float(bridge.hvacPowerState) + hvacAcMode = Float(bridge.hvacAcMode) + hvacRecirc = Float(bridge.hvacRecirc) + hvacKeepClimateOn = Float(bridge.hvacKeepClimateOn) madsActive = bridge.madsActive > 0 selfdriveActive = bridge.selfdriveActive > 0 experimentalMode = bridge.experimentalMode > 0 @@ -89,6 +101,7 @@ struct CarState: Codable { c.packTMin = packTMin != 0 ? packTMin * 1.8 + 32 : 0 c.packTMax = packTMax != 0 ? packTMax * 1.8 + 32 : 0 c.acTemp = acTemp != 0 ? acTemp * 1.8 + 32 : 0 + c.acTempRight = acTempRight != 0 ? acTempRight * 1.8 + 32 : 0 if Self.speedLimitSignsInKm() { c.fusedSpeedLimit = fusedSpeedLimit * Self.kmToMiles } @@ -124,6 +137,12 @@ struct CarState: Codable { packTMax = try c.decodeIfPresent(Float.self, forKey: .packTMax) ?? 0 odometer = try c.decodeIfPresent(Float.self, forKey: .odometer) ?? 0 acTemp = try c.decodeIfPresent(Float.self, forKey: .acTemp) ?? 0 + acTempRight = try c.decodeIfPresent(Float.self, forKey: .acTempRight) ?? 0 + hvacFanLevel = try c.decodeIfPresent(Float.self, forKey: .hvacFanLevel) ?? 0 + hvacPowerState = try c.decodeIfPresent(Float.self, forKey: .hvacPowerState) ?? 0 + hvacAcMode = try c.decodeIfPresent(Float.self, forKey: .hvacAcMode) ?? 0 + hvacRecirc = try c.decodeIfPresent(Float.self, forKey: .hvacRecirc) ?? 0 + hvacKeepClimateOn = try c.decodeIfPresent(Float.self, forKey: .hvacKeepClimateOn) ?? 0 madsActive = try c.decodeIfPresent(Bool.self, forKey: .madsActive) ?? false selfdriveActive = try c.decodeIfPresent(Bool.self, forKey: .selfdriveActive) ?? false experimentalMode = try c.decodeIfPresent(Bool.self, forKey: .experimentalMode) ?? false diff --git a/dashpilot-ios/dashpilot/Navigation/AppRouter.swift b/dashpilot-ios/dashpilot/Navigation/AppRouter.swift index e6018a89..3b467cbf 100644 --- a/dashpilot-ios/dashpilot/Navigation/AppRouter.swift +++ b/dashpilot-ios/dashpilot/Navigation/AppRouter.swift @@ -4,6 +4,7 @@ enum AppRoute: Hashable { case settings case themePicker case battery + case climate case automations case controls case setup diff --git a/dashpilot-ios/dashpilot/UI/AutomationsView.swift b/dashpilot-ios/dashpilot/UI/AutomationsView.swift index b9c065fe..45514abd 100644 --- a/dashpilot-ios/dashpilot/UI/AutomationsView.swift +++ b/dashpilot-ios/dashpilot/UI/AutomationsView.swift @@ -8,7 +8,7 @@ private let fingerCounts = 3...5 private let fingerActionsKey = "finger_actions" /// Minutes the keep-climate-on window can run (matches the firmware clamp). -private let climateKeepMinuteRange = 1...60 +let climateKeepMinuteRange = 1...60 // Matches the firmware clamp. private let sportKickdownPercentOptions = Array(stride(from: 10, through: 95, by: 5)) @@ -299,7 +299,7 @@ private struct SectionLabel: View { /// A `.dashSurface` card with an icon, title/subtitle, a trailing toggle, and /// an optional footer rendered inside the same card (Android `AutomationRow`). -private struct AutomationRow: View { +struct AutomationRow: View { let icon: String let title: String let subtitle: String? @@ -369,7 +369,7 @@ extension AutomationRow where Footer == EmptyView { /// "Label N unit" line inside an automation card; tapping the value expands a /// wheel over `options` (Android `NumberPickerFooter`). -private struct ValuePickerFooter: View { +struct ValuePickerFooter: View { let label: String let unit: String let options: [Int] diff --git a/dashpilot-ios/dashpilot/UI/BatteryView.swift b/dashpilot-ios/dashpilot/UI/BatteryView.swift index b2ba57ef..fcf92d05 100644 --- a/dashpilot-ios/dashpilot/UI/BatteryView.swift +++ b/dashpilot-ios/dashpilot/UI/BatteryView.swift @@ -21,6 +21,19 @@ struct BatteryView: View { Spacer().frame(height: 24) socCard + if let preheat = controlById("battery_preheat") { + Spacer().frame(height: 28) + Text("Preheat") + .foregroundColor(.dashTextMuted) + .font(.system(size: 13, weight: .semibold)) + Spacer().frame(height: 8) + ControlActionButton( + action: preheat, + enabled: connectionVM.bleManager != nil, + onTap: { preheat.perform(connectionVM.bleManager) }, + onLongPress: {} + ) + } Spacer().frame(height: 28) @@ -48,6 +61,7 @@ struct BatteryView: View { ("Temp Max", tempText(car?.packTMax)) ]) + Spacer().frame(height: 32) } .padding(DashMetrics.screenPadding) diff --git a/dashpilot-ios/dashpilot/UI/ClimateView.swift b/dashpilot-ios/dashpilot/UI/ClimateView.swift new file mode 100644 index 00000000..a1f8c8d0 --- /dev/null +++ b/dashpilot-ios/dashpilot/UI/ClimateView.swift @@ -0,0 +1,203 @@ +import SwiftUI + +struct ClimateView: View { + + @Environment(\.dismiss) private var dismiss + @Environment(ConnectionViewModel.self) private var connectionVM + + @AppStorage("climate_keep_automation") private var climateKeep: Bool = false + @AppStorage("climate_keep_minutes") private var climateKeepMinutes: Int = 5 + @State private var minutesPushTask: Task? + @State private var minutesWheelExpanded = false + + @State private var dash: DashState? + + private var car: CarState? { dash?.carState } + private var imperial: Bool { dash?.displaySettings.useImperial ?? false } + // A real setpoint is never 0, so 0 means no UI_hvacRequest seen yet. + private var live: Bool { (car?.acTemp ?? 0) != 0 } + + var body: some View { + ZStack { + Color.dashBackground + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ScreenHeader(title: "Climate") { dismiss() } + + Spacer().frame(height: 24) + + setpointCard + + Spacer().frame(height: 28) + + sectionTitle("Automation") + Spacer().frame(height: 8) + AutomationRow( + icon: "fanblades.fill", + title: "Keep climate on", + subtitle: "Keep the climate on when you leave the car. The automation stops after the set time, or when you return to the car.", + isOn: $climateKeep + ) { + if climateKeep { + ValuePickerFooter( + label: "Stop after", + unit: "min", + options: Array(climateKeepMinuteRange), + value: $climateKeepMinutes, + expanded: $minutesWheelExpanded + ) + } + } + + Spacer().frame(height: 28) + + section("Setpoints", rows: [ + ("Driver", setpointText(car?.acTemp)), + ("Passenger", setpointText(car?.acTempRight)) + ]) + + Spacer().frame(height: 28) + + section("Settings", rows: [ + ("Power", enumText(car?.hvacPowerState, Self.powerLabels)), + ("Fan", fanText(car?.hvacFanLevel)), + ("A/C", enumText(car?.hvacAcMode, Self.acLabels)), + ("Recirculation", enumText(car?.hvacRecirc, Self.recircLabels)), + ("Keep climate", enumText(car?.hvacKeepClimateOn, Self.keepLabels)) + ]) + + + Spacer().frame(height: 32) + } + .padding(DashMetrics.screenPadding) + } + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { minutesWheelExpanded = false } + } + .navigationBarHidden(true) + .task { + for await state in connectionVM.dashStateStream() { + dash = state + } + } + .onChange(of: connectionVM.connectionStatus) { _, status in + if status == .disconnected { + dash = nil + } + } + .onChange(of: climateKeep) { _, newValue in + if !newValue { + minutesWheelExpanded = false + } + if let manager = connectionVM.bleManager { + VehicleControl.sendClimateKeep(manager, enabled: newValue) + } + } + .onChange(of: climateKeepMinutes) { _, newValue in + minutesPushTask?.cancel() + minutesPushTask = Task { + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + if let manager = connectionVM.bleManager { + VehicleControl.sendClimateKeepDuration(manager, minutes: newValue) + } + } + } + } + + // MARK: - Setpoint card + + private var setpointCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + Text(setpointText(car?.acTemp)) + .foregroundColor(.white) + .font(.system(size: 44, weight: .bold)) + Spacer() + IconChip( + systemName: "snowflake", + tint: .dashAccent, + background: Color.dashAccent.opacity(0.14) + ) + } + Text("Driver setpoint") + .foregroundColor(.dashTextMuted) + .font(.system(size: 13)) + } + .padding(16) + .background(Color.dashSurface) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) + } + + // MARK: - Sections + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .foregroundColor(.dashTextMuted) + .font(.system(size: 13, weight: .semibold)) + } + + private func section(_ title: String, rows: [(String, String)]) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle(title) + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + HStack { + Text(row.0) + .foregroundColor(.dashTextMuted) + .font(.system(size: 15)) + Spacer() + Text(row.1) + .foregroundColor(.white) + .font(.system(size: 16, weight: .semibold)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + if index < rows.count - 1 { + Divider() + .background(Color.dashBorder) + .padding(.leading, 16) + } + } + } + .background(Color.dashSurface) + .clipShape(RoundedRectangle(cornerRadius: DashMetrics.corner)) + } + } + + // MARK: - Formatters + + private static let powerLabels = ["Off", "On", "Preconditioning", "Overheat protection (fan)", "Overheat protection"] + private static let acLabels = ["Auto", "Off", "On"] + private static let recircLabels = ["Auto", "Recirculate", "Fresh air"] + private static let keepLabels = ["Off", "Keep", "Dog mode", "Camp mode"] + + private func setpointText(_ value: Float?) -> String { + guard let value, value != 0 else { return "—" } + let lo: Float = imperial ? 59 : 15 + let hi: Float = imperial ? 82.4 : 28 + if value <= lo { return "LO" } + if value >= hi { return "HI" } + return "\(Int(value.rounded()))°" + } + + private func enumText(_ value: Float?, _ labels: [String]) -> String { + guard live, let value else { return "—" } + let index = Int(value.rounded()) + return labels.indices.contains(index) ? labels[index] : "—" + } + + private func fanText(_ value: Float?) -> String { + guard live, let value else { return "—" } + switch Int(value.rounded()) { + case 0: return "Off" + case 11: return "Auto" + case 1...10: return "\(Int(value.rounded()))" + default: return "—" + } + } +} diff --git a/dashpilot-ios/dashpilot/UI/HomeView.swift b/dashpilot-ios/dashpilot/UI/HomeView.swift index ddfe7e05..b8766b20 100644 --- a/dashpilot-ios/dashpilot/UI/HomeView.swift +++ b/dashpilot-ios/dashpilot/UI/HomeView.swift @@ -147,12 +147,17 @@ struct HomeView: View { .buttonStyle(.plain) } HStack(spacing: DashMetrics.gridGap) { - InfoWidget( - icon: "snowflake", - label: "AC Temp", - value: acTempText, - fillHeight: fillHeight - ) + Button { + navigationPath.append(AppRoute.climate) + } label: { + InfoWidget( + icon: "snowflake", + label: "AC Temp", + value: acTempText, + fillHeight: fillHeight + ) + } + .buttonStyle(.plain) InfoWidget( icon: "gauge.with.dots.needle.bottom.50percent", label: "Odometer", diff --git a/dashpilot-ios/dashpilot/dashpilotApp.swift b/dashpilot-ios/dashpilot/dashpilotApp.swift index 68d16905..94d472a2 100644 --- a/dashpilot-ios/dashpilot/dashpilotApp.swift +++ b/dashpilot-ios/dashpilot/dashpilotApp.swift @@ -43,6 +43,8 @@ struct dashpilotApp: App { ThemePickerView() case .battery: BatteryView() + case .climate: + ClimateView() case .automations: AutomationsView() case .controls: