diff --git a/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt
index b56f7d74..3dc98ac8 100644
--- a/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt
+++ b/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt
@@ -361,6 +361,7 @@ class CarEditorFragment : BaseFragment() {
"car_smart_44_white_silver",
"car_smart_44_fl_black",
"car_zoe_black",
+ "car_egolf_white",
"car_vwup_black",
"car_vwup_blue",
"car_vwup_red",
diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt
index da003e86..7cd1d09d 100644
--- a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt
+++ b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt
@@ -10,9 +10,11 @@ import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.ImageView
+import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
+import com.google.android.material.slider.Slider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.openvehicles.OVMS.R
@@ -33,6 +35,14 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener {
private lateinit var climateActionsAdapter: QuickActionsAdapter
+ /**
+ * Right-hand button column. Used only where the card carries the target
+ * temperature slider (VW e-Golf): the start button sits there because that
+ * is where the thumb falls with the phone in the right hand. Everywhere
+ * else it stays empty and gone, so those vehicles keep the layout they had.
+ */
+ private lateinit var climateActionsRightAdapter: QuickActionsAdapter
+
@@ -52,10 +62,80 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener {
climateActionsRecyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
climateActionsRecyclerView.adapter = climateActionsAdapter
+ val rightRecyclerView = findViewById(R.id.climateActionsRight) as RecyclerView
+ climateActionsRightAdapter = QuickActionsAdapter(context)
+ rightRecyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
+ rightRecyclerView.adapter = climateActionsRightAdapter
+
+ initialiseTargetTempSlider()
initialiseCarRendering(carData)
initialiseClimateControls(carData)
}
+ /**
+ * Target temperature for pre-conditioning.
+ *
+ * Only shown for vehicles whose module can set it — currently the VW e-Golf,
+ * where the value lives in the car's stored BatteryControl profile and the
+ * module writes it back with `xvg cctemp`. Set up once here rather than in
+ * [initialiseClimateControls], which runs again on every data update and
+ * would otherwise stack listeners.
+ */
+ private fun initialiseTargetTempSlider() {
+ val group = findViewById(R.id.ccTempGroup) as LinearLayout
+ if (carData?.car_type != "VWEG") {
+ group.visibility = View.GONE
+ return
+ }
+ group.visibility = View.VISIBLE
+
+ val slider = findViewById(R.id.ccTempSlider) as Slider
+ slider.addOnChangeListener { _, value, _ -> showTargetTemp(value) }
+ slider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
+ override fun onStartTrackingTouch(s: Slider) {}
+ override fun onStopTrackingTouch(s: Slider) {
+ // Send on release only — sending while dragging would put a write
+ // on the car's comfort bus for every step.
+ sendCommand(
+ getString(R.string.climate_target_temp, formatTemp(s.value)),
+ "7,xvg cctemp " + formatTemp(s.value),
+ this@ClimateFragment
+ )
+ }
+ })
+ showTargetTemp(slider.value)
+
+ // The target temperature is not carried by the v2 protocol, so it cannot
+ // come in with the metrics — ask the module for it when the tab opens.
+ sendCommand("", "7,xvg ccstatus", this)
+ }
+
+ private fun formatTemp(value: Float): String = DecimalFormat("0.0").format(value)
+
+ private fun showTargetTemp(value: Float) {
+ val label = findViewById(R.id.ccTempLabel) as TextView
+ // Just the value — the slider directly beneath it makes clear what it is.
+ label.text = formatTemp(value) + " °C"
+ }
+
+ /**
+ * Applies `cctemp=22.0 current=32 valid=1` as reported by the module.
+ *
+ * `valid=0` means the module has not read the car's profile yet, so the
+ * value carries no information — leave the slider where it is rather than
+ * snapping it to a placeholder.
+ */
+ private fun applyClimateStatus(text: String) {
+ if (Regex("valid=0").containsMatchIn(text)) return
+ Regex("cctemp=([0-9.]+)").find(text)?.groupValues?.get(1)?.toFloatOrNull()?.let {
+ val slider = findViewById(R.id.ccTempSlider) as Slider
+ if (it >= slider.valueFrom && it <= slider.valueTo) {
+ slider.value = it
+ showTargetTemp(it)
+ }
+ }
+ }
+
private fun initialiseCarRendering(carData: CarData?) {
val carImageView = findViewById(R.id.battIndicatorImg) as ImageView
val layers = carData?.let { CarRenderingUtils.getTopDownCarLayers(it, requireContext(), climate = true, heat = carData.car_hvac_on) }
@@ -167,13 +247,32 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener {
climateActionsAdapter.mData.clear()
climateActionsAdapter.setCarData(carData)
- climateActionsAdapter.mData += ClimateQuickAction({getService()})
+ climateActionsRightAdapter.mData.clear()
+ climateActionsRightAdapter.setCarData(carData)
+
+ val leftColumn = findViewById(R.id.climateActions) as RecyclerView
+ val rightColumn = findViewById(R.id.climateActionsRight) as RecyclerView
+ if (carData?.car_type == "VWEG") {
+ // Start on the right, where the thumb falls with the phone in the right
+ // hand. There is deliberately no "climatise without the cable" button:
+ // that profile bit is owned by the module, which sets it for a climate
+ // command and clears it for a charge — a user-facing toggle would fight
+ // the firmware and show a state that changes under the user's hands.
+ climateActionsRightAdapter.mData += ClimateQuickAction({getService()})
+ leftColumn.visibility = View.GONE
+ rightColumn.visibility = View.VISIBLE
+ } else {
+ climateActionsAdapter.mData += ClimateQuickAction({getService()})
+ leftColumn.visibility = View.VISIBLE
+ rightColumn.visibility = View.GONE
+ }
if (carData?.car_type in listOf("NL","SE","SQ","VWUP","VWUP.T26","RZ","RZ2")
|| carData?.car_type.orEmpty().startsWith("VA")
|| carData?.car_type.orEmpty().startsWith("VB")
|| carData?.car_type.orEmpty().startsWith("OAE"))
climateActionsAdapter.mData += ClimateScheduleQuickAction({getService()})
climateActionsAdapter.notifyDataSetChanged()
+ climateActionsRightAdapter.notifyDataSetChanged()
}
override fun update(carData: CarData?) {
@@ -187,6 +286,25 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener {
val resCode = result[1].toInt()
val resText = if (result.size > 2) result[2] else ""
val cmdMessage = getSentCommandMessage(result[0])
+ // Status reply from `xvg ccstatus`: sync slider and button.
+ if (resCode == 0 && resText.contains("cctemp=")) {
+ applyClimateStatus(resText)
+ cancelCommand()
+ return
+ }
+ // Anything else we sent for this car changes the stored profile, and the
+ // module may well have refused it — a sleeping car cannot be written to.
+ // Never leave the slider showing a value the car does not hold: ask what
+ // it actually is now. The reply lands in the branch above.
+ // result[0] is the command *code*, not the text we sent — BaseFragment
+ // keys its message map on command.split(",")[0]. 7 is "execute command",
+ // and for this vehicle the only ones this tab sends are the xvg writes.
+ if (carData?.car_type == "VWEG" && result[0] == "7") {
+ if (resText.isNotEmpty())
+ Toast.makeText(activity, resText, Toast.LENGTH_LONG).show()
+ sendCommand("", "7,xvg ccstatus", this)
+ return
+ }
val context: Context? = activity
if (context != null) {
when (resCode) {
diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt
index 7dafc9ee..900da31f 100644
--- a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt
+++ b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt
@@ -102,6 +102,7 @@ class CarEditorFragment : BaseFragment() {
VehicleType("smart_eq", "Smart EQ (ForTwo)", listOf("red", "black", "white", "fl_black", "fl_white", "fl_red", "cabrio_black", "cabrio_crystalwhite", "cabrio_grey", "cabrio_lavaorange")),
VehicleType("smart_44", "Smart ForFour", listOf("black", "white_silver", "fl_black")),
VehicleType("vwup", "VW e-Up", listOf("black", "blue", "red", "silver", "white", "yellow")),
+ VehicleType("egolf", "VW e-Golf", listOf("white")),
VehicleType("zoe", "Renault Zoe", listOf("black", "brown", "grey", "hellblau", "lila", "red", "white", "ytriumgrau")),
VehicleType("mgzs", "MG ZS EV", listOf("white", "blue", "lightblue", "red", "black")),
VehicleType("edeliver3", "Maxus eDeliver 3", listOf("white")),
@@ -818,6 +819,7 @@ class CarEditorFragment : BaseFragment() {
"map_car_twizy_snowwhiteandflameorange",
"map_car_twizy_snowwhiteandurbanblue",
"map_car_twizy_snowwhitewithblack",
+ "map_car_egolf_white",
"map_car_vwup_black",
"map_car_vwup_blue",
"map_car_vwup_red",
@@ -897,6 +899,7 @@ class CarEditorFragment : BaseFragment() {
"car_thinkcity_classicblack",
"car_thinkcity_skyblue",
"car_twizy",
+ "car_egolf_white",
"car_vwup_black",
"car_vwup_blue",
"car_vwup_red",
@@ -982,6 +985,7 @@ class CarEditorFragment : BaseFragment() {
"car_smart_44_white_silver",
"car_smart_44_fl_black",
"car_zoe_black",
+ "car_egolf_white",
"car_vwup_black",
"car_vwup_blue",
"car_vwup_red",
diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt
index 96f042b3..8f690676 100644
--- a/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt
+++ b/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt
@@ -1,6 +1,10 @@
package com.openvehicles.OVMS.ui2.rendering
import android.content.Context
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.graphics.drawable.Animatable
import android.graphics.drawable.VectorDrawable
@@ -310,18 +314,82 @@ object CarRenderingUtils {
finalDrawable.start()
}
- layers = layers.plus(finalDrawable)
+ layers = layers.plus(fitToBase(finalDrawable, layers.first()))
} else {
Log.e("DrawableError", "Could not load R.drawable.avd_animated_ac_arrows")
val staticArrows = ContextCompat.getDrawable(context, R.drawable.topview_ac_arrows)
if (staticArrows != null) {
val vectorDrawable = staticArrows.mutate() as VectorDrawable
vectorDrawable.setTint(tintColor)
- layers = layers.plus(vectorDrawable)
+ layers = layers.plus(fitToBase(vectorDrawable, layers.first()))
}
}
}
return layers
}
+
+ /**
+ * The AC arrows are a vector sized in dp and drawn for the Leaf's 320x560 top
+ * view. Every other car image is a nodpi bitmap, so the arrows come out far
+ * bigger in pixels, the LayerDrawable takes their size as its own, and the car
+ * underneath gets stretched to their aspect ratio. Reporting the base image's
+ * size keeps the composite unchanged whether the overlay is there or not.
+ */
+ private fun fitToBase(overlay: Drawable, base: Drawable): Drawable {
+ if (base.intrinsicWidth <= 0 || base.intrinsicHeight <= 0) return overlay
+ return FittedOverlayDrawable(overlay, base.intrinsicWidth, base.intrinsicHeight)
+ }
+
+ private class FittedOverlayDrawable(
+ private val inner: Drawable,
+ private val width: Int,
+ private val height: Int
+ ) : Drawable(), Drawable.Callback {
+
+ init {
+ inner.callback = this
+ }
+
+ override fun getIntrinsicWidth() = width
+
+ override fun getIntrinsicHeight() = height
+
+ override fun onBoundsChange(bounds: Rect) {
+ // Fit centred, keeping the overlay's own aspect ratio, so the arrows
+ // stay over the cabin instead of being stretched across the car.
+ val iw = if (inner.intrinsicWidth > 0) inner.intrinsicWidth else bounds.width()
+ val ih = if (inner.intrinsicHeight > 0) inner.intrinsicHeight else bounds.height()
+ if (iw <= 0 || ih <= 0) {
+ inner.bounds = bounds
+ return
+ }
+ val scale = minOf(bounds.width() / iw.toFloat(), bounds.height() / ih.toFloat())
+ val w = (iw * scale).toInt()
+ val h = (ih * scale).toInt()
+ val left = bounds.left + (bounds.width() - w) / 2
+ val top = bounds.top + (bounds.height() - h) / 2
+ inner.setBounds(left, top, left + w, top + h)
+ }
+
+ override fun draw(canvas: Canvas) = inner.draw(canvas)
+
+ override fun setAlpha(alpha: Int) {
+ inner.alpha = alpha
+ }
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {
+ inner.colorFilter = colorFilter
+ }
+
+ @Deprecated("Deprecated in Drawable", ReplaceWith("PixelFormat.TRANSLUCENT"))
+ override fun getOpacity() = PixelFormat.TRANSLUCENT
+
+ override fun invalidateDrawable(who: Drawable) = invalidateSelf()
+
+ override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) =
+ scheduleSelf(what, `when`)
+
+ override fun unscheduleDrawable(who: Drawable, what: Runnable) = unscheduleSelf(what)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-nodpi/car_egolf_white.png b/app/src/main/res/drawable-nodpi/car_egolf_white.png
new file mode 100644
index 00000000..f670bda0
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/car_egolf_white.png differ
diff --git a/app/src/main/res/drawable-nodpi/map_car_egolf_white.png b/app/src/main/res/drawable-nodpi/map_car_egolf_white.png
new file mode 100644
index 00000000..3cb13b34
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/map_car_egolf_white.png differ
diff --git a/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png b/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png
new file mode 100644
index 00000000..44bf02bf
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png differ
diff --git a/app/src/main/res/layout/fragment_climate.xml b/app/src/main/res/layout/fragment_climate.xml
index 1d1fcbff..2d1cd9b0 100644
--- a/app/src/main/res/layout/fragment_climate.xml
+++ b/app/src/main/res/layout/fragment_climate.xml
@@ -12,9 +12,9 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="20dp"
- android:layout_marginTop="50dp"
+ android:layout_marginTop="12dp"
android:layout_marginEnd="20dp"
- android:layout_marginBottom="24dp"
+ android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/climate_card"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -36,51 +36,91 @@
android:layout_height="wrap_content"
android:padding="12dp">
+
+
+
+
+
+
+ app:layout_constraintBottom_toBottomOf="@+id/ambientTemp"
+ app:layout_constraintStart_toEndOf="@+id/ambientTemp"
+ app:layout_constraintTop_toTopOf="@+id/ambientTemp"
+ app:layout_constraintVertical_bias="0.26999998"
+ tools:text="°C" />
+ app:layout_constraintStart_toStartOf="@+id/ambientTemp"
+ app:layout_constraintTop_toBottomOf="@+id/ambientTemp" />
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/ClimateTxtD"
+ tools:visibility="visible">
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 30fbc25a..f62fa29a 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -891,6 +891,7 @@
Löschen
Kopieren
Mehr
+ Zieltemperatur %s
Darstellung
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9f776e46..fa3d41f6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -979,6 +979,7 @@
Copy
More
Stop restart
+ Target %s
Appearance