From 13e71915f3c5cf56cff1151a1afc7435294bb35f Mon Sep 17 00:00:00 2001 From: SliDeeN Date: Fri, 24 Jul 2026 08:10:10 +0200 Subject: [PATCH] add user choice to automation direction --- .../control/automation/AutomationDecision.kt | 16 ++++-- .../control/automation/AutomationSettings.kt | 17 +++++- .../mg4/control/service/MG4ControlService.kt | 5 +- .../control/service/ProfileConfirmOverlay.kt | 9 +++- .../com/mg4/control/ui/AutomationFragment.kt | 23 ++++++++ .../main/res/layout/fragment_automation.xml | 54 +++++++++++++++++++ app/src/main/res/values-de/strings.xml | 4 ++ app/src/main/res/values-en/strings.xml | 4 ++ app/src/main/res/values-es/strings.xml | 4 ++ app/src/main/res/values-it/strings.xml | 4 ++ app/src/main/res/values-pt/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ .../automation/AutomationDecisionTest.kt | 35 ++++++++---- 13 files changed, 163 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt b/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt index d7c365d..7c0dc10 100644 --- a/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt +++ b/app/src/main/java/com/mg4/control/automation/AutomationDecision.kt @@ -6,15 +6,23 @@ object AutomationDecision { enum class Outcome { NOT_APPLICABLE, APPLY } /** - * APPLY ssi : [enabled] ET [temp] lisible (non null/NaN) ET [profileExists] - * ET [temp] <= [threshold] (borne incluse — déclenchement quand il fait ≤ seuil). + * APPLY ssi : [enabled] ET [temp] lisible (non null/NaN) ET [profileExists] ET la condition + * selon [direction] (borne incluse) : + * BELOW → [temp] <= [threshold] ; ABOVE → [temp] >= [threshold]. * Sinon NOT_APPLICABLE. */ - fun evaluate(enabled: Boolean, temp: Float?, threshold: Int, profileExists: Boolean): Outcome = when { + fun evaluate( + enabled: Boolean, + temp: Float?, + threshold: Int, + direction: AutomationSettings.Direction, + profileExists: Boolean + ): Outcome = when { !enabled -> Outcome.NOT_APPLICABLE temp == null || temp.isNaN() -> Outcome.NOT_APPLICABLE !profileExists -> Outcome.NOT_APPLICABLE - temp <= threshold.toFloat() -> Outcome.APPLY + direction == AutomationSettings.Direction.BELOW && temp <= threshold.toFloat() -> Outcome.APPLY + direction == AutomationSettings.Direction.ABOVE && temp >= threshold.toFloat() -> Outcome.APPLY else -> Outcome.NOT_APPLICABLE } } diff --git a/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt b/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt index e475e43..21cda7b 100644 --- a/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt +++ b/app/src/main/java/com/mg4/control/automation/AutomationSettings.kt @@ -10,16 +10,21 @@ object AutomationSettings { const val KEY_THRESHOLD = "automation_temp_threshold" const val KEY_PROFILE_ID = "automation_temp_profile_id" const val KEY_AUTO_EXECUTE = "automation_temp_auto_execute" + const val KEY_DIRECTION = "automation_temp_direction" const val DEFAULT_THRESHOLD = 25 const val MIN_TEMP = 0 const val MAX_TEMP = 60 + /** Sens du déclenchement : sous le seuil (froid) ou au-dessus (chaud). */ + enum class Direction { BELOW, ABOVE } + data class Config( val enabled: Boolean, val threshold: Int, val profileId: String, - val autoExecute: Boolean + val autoExecute: Boolean, + val direction: Direction ) fun read(context: Context): Config { @@ -28,10 +33,18 @@ object AutomationSettings { enabled = p.getBoolean(KEY_ENABLED, false), threshold = p.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD), profileId = p.getString(KEY_PROFILE_ID, "") ?: "", - autoExecute = p.getBoolean(KEY_AUTO_EXECUTE, false) + autoExecute = p.getBoolean(KEY_AUTO_EXECUTE, false), + direction = readDirection(context) ) } + /** Direction persistée, repli BELOW si absente/invalide. */ + fun readDirection(context: Context): Direction { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_DIRECTION, Direction.BELOW.name) ?: Direction.BELOW.name + return runCatching { Direction.valueOf(raw) }.getOrDefault(Direction.BELOW) + } + /** Clampe une saisie de seuil dans [MIN_TEMP, MAX_TEMP] ; null/vide => défaut. */ fun clampTemp(raw: Int?): Int = (raw ?: DEFAULT_THRESHOLD).coerceIn(MIN_TEMP, MAX_TEMP) } diff --git a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt index ca9fbee..6c240e2 100644 --- a/app/src/main/java/com/mg4/control/service/MG4ControlService.kt +++ b/app/src/main/java/com/mg4/control/service/MG4ControlService.kt @@ -518,13 +518,13 @@ class MG4ControlService : Service() { MG4Hardware.whenKatman1Ready { val temp = MG4Hardware.getOutsideTempCelsius() - val outcome = AutomationDecision.evaluate(cfg.enabled, temp, cfg.threshold, profile != null) + val outcome = AutomationDecision.evaluate(cfg.enabled, temp, cfg.threshold, cfg.direction, profile != null) if (outcome != AutomationDecision.Outcome.APPLY || profile == null || temp == null) { AppLogger.i(TAG, "Auto temp: non applicable (temp=$temp seuil=${cfg.threshold} profil=${profile?.name}) → fallback") onFallback(); return@whenKatman1Ready } if (cfg.autoExecute) { - AppLogger.i(TAG, "Auto temp → application directe '${profile.name}' (temp=$temp ≤ ${cfg.threshold})") + AppLogger.i(TAG, "Auto temp → application directe '${profile.name}' (temp=$temp dir=${cfg.direction} seuil=${cfg.threshold})") ProfileApplier.apply(profile, autoStart = true) { ok -> AppLogger.i(TAG, "Auto temp appliqué — ok=$ok") } } else { AppLogger.i(TAG, "Auto temp → popup confirmation '${profile.name}'") @@ -533,6 +533,7 @@ class MG4ControlService : Service() { profile = profile, threshold = cfg.threshold, currentTemp = temp, + direction = cfg.direction, onConfirmed = { CoroutineScope(Dispatchers.IO).launch { ProfileApplier.apply(profile, autoStart = true) { ok -> AppLogger.i(TAG, "Auto temp OUI '${profile.name}' — ok=$ok") } diff --git a/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt b/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt index 6a5bfbb..2dff1ba 100644 --- a/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt +++ b/app/src/main/java/com/mg4/control/service/ProfileConfirmOverlay.kt @@ -12,6 +12,7 @@ import android.view.WindowManager import android.widget.TextView import com.google.android.material.button.MaterialButton import com.mg4.control.R +import com.mg4.control.automation.AutomationSettings import com.mg4.control.debug.AppLogger import com.mg4.control.hardware.VehicleWriteGate import com.mg4.control.model.DrivingProfile @@ -37,10 +38,11 @@ object ProfileConfirmOverlay { profile: DrivingProfile, threshold: Int, currentTemp: Float, + direction: AutomationSettings.Direction, onConfirmed: () -> Unit, onDeclined: () -> Unit ) { - handler.post { showOnMain(context, profile, threshold, currentTemp, onConfirmed, onDeclined) } + handler.post { showOnMain(context, profile, threshold, currentTemp, direction, onConfirmed, onDeclined) } } private fun showOnMain( @@ -48,6 +50,7 @@ object ProfileConfirmOverlay { profile: DrivingProfile, threshold: Int, currentTemp: Float, + direction: AutomationSettings.Direction, onConfirmed: () -> Unit, onDeclined: () -> Unit ) { @@ -63,8 +66,10 @@ object ProfileConfirmOverlay { val view = LayoutInflater.from(themed).inflate(R.layout.overlay_profile_confirm, null) val tempStr = String.format(java.util.Locale.getDefault(), "%.1f", currentTemp) + val msgRes = if (direction == AutomationSettings.Direction.ABOVE) + R.string.automation_confirm_msg_above else R.string.automation_confirm_msg view.findViewById(R.id.confirm_message).text = - localized.getString(R.string.automation_confirm_msg, threshold, tempStr, profile.name) + localized.getString(msgRes, threshold, tempStr, profile.name) // Un seul chemin de sortie : garde-fou pour ne déclencher qu'un callback. var done = false diff --git a/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt index 269b8a4..2ee6ded 100644 --- a/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt +++ b/app/src/main/java/com/mg4/control/ui/AutomationFragment.kt @@ -1,6 +1,7 @@ package com.mg4.control.ui import android.content.Context +import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -12,6 +13,7 @@ import android.widget.EditText import android.widget.Spinner import android.widget.Switch import androidx.fragment.app.Fragment +import com.google.android.material.button.MaterialButton import com.mg4.control.R import com.mg4.control.automation.AutomationSettings import com.mg4.control.model.DrivingProfile @@ -62,6 +64,27 @@ class AutomationFragment : Fragment() { prefs.edit().putBoolean(AutomationSettings.KEY_AUTO_EXECUTE, checked).apply() } + // ── Sens du déclenchement (inférieure / supérieure au seuil) ───────── + val btnDirBelow = view.findViewById(R.id.btn_dir_below) + val btnDirAbove = view.findViewById(R.id.btn_dir_above) + val accentDim = requireContext().getColor(R.color.dash_accent_dim) + val inactive = requireContext().getColor(R.color.dash_btn) + + fun highlightDirection(dir: AutomationSettings.Direction) { + btnDirBelow.backgroundTintList = ColorStateList.valueOf( + if (dir == AutomationSettings.Direction.BELOW) accentDim else inactive) + btnDirAbove.backgroundTintList = ColorStateList.valueOf( + if (dir == AutomationSettings.Direction.ABOVE) accentDim else inactive) + } + highlightDirection(AutomationSettings.readDirection(requireContext())) + + fun setDirection(dir: AutomationSettings.Direction) { + prefs.edit().putString(AutomationSettings.KEY_DIRECTION, dir.name).apply() + highlightDirection(dir) + } + btnDirBelow.setOnClickListener { setDirection(AutomationSettings.Direction.BELOW) } + btnDirAbove.setOnClickListener { setDirection(AutomationSettings.Direction.ABOVE) } + setupSpinner(spinner, prefs) } diff --git a/app/src/main/res/layout/fragment_automation.xml b/app/src/main/res/layout/fragment_automation.xml index 89be8a9..9a07dd4 100644 --- a/app/src/main/res/layout/fragment_automation.xml +++ b/app/src/main/res/layout/fragment_automation.xml @@ -1,5 +1,6 @@ @@ -42,6 +43,59 @@ android:orientation="vertical" android:visibility="gone"> + + + + + + + + + + + + + Die Außentemperatur liegt unter %1$d°C\n(%2$s°C aktuelle Temperatur)\nProfil „%3$s“ anwenden? JA NEIN + Auslösen, wenn die Außentemperatur ist: + Unter + Über + Die Außentemperatur liegt über %1$d°C\n(%2$s°C aktuelle Temperatur)\nProfil „%3$s“ anwenden? diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index defbc84..53c47c9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -301,4 +301,8 @@ The outside temperature is below %1$d°C\n(%2$s°C current temperature)\nApply profile “%3$s”? YES NO + Trigger when the outside temperature is: + Below + Above + The outside temperature is above %1$d°C\n(%2$s°C current temperature)\nApply profile “%3$s”? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 80e1943..660c088 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -301,4 +301,8 @@ La temperatura exterior está por debajo de %1$d°C\n(%2$s°C temperatura actual)\n¿Aplicar el perfil «%3$s»? NO + Activar cuando la temperatura exterior sea: + Inferior a + Superior a + La temperatura exterior está por encima de %1$d°C\n(%2$s°C temperatura actual)\n¿Aplicar el perfil «%3$s»? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fc5ddcb..6880ab1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -301,4 +301,8 @@ La temperatura esterna è sotto i %1$d°C\n(%2$s°C temperatura attuale)\nApplicare il profilo «%3$s»? NO + Attiva quando la temperatura esterna è: + Inferiore a + Superiore a + La temperatura esterna è sopra i %1$d°C\n(%2$s°C temperatura attuale)\nApplicare il profilo «%3$s»? diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0d226c2..9ec444b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -301,4 +301,8 @@ A temperatura exterior está abaixo de %1$d°C\n(%2$s°C temperatura atual)\nAplicar o perfil «%3$s»? SIM NÃO + Acionar quando a temperatura exterior for: + Inferior a + Superior a + A temperatura exterior está acima de %1$d°C\n(%2$s°C temperatura atual)\nAplicar o perfil «%3$s»? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0aae610..72a0e1b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,4 +303,8 @@ La température extérieure est en dessous de %1$d°C\n(%2$s°C température actuelle)\nVoulez-vous appliquer le profil « %3$s » ? OUI NON + Déclencher si la température extérieure est : + Inférieure à + Supérieure à + La température extérieure est au-dessus de %1$d°C\n(%2$s°C température actuelle)\nVoulez-vous appliquer le profil « %3$s » ? diff --git a/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt b/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt index d36edeb..aa19ba5 100644 --- a/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt +++ b/app/src/test/java/com/mg4/control/automation/AutomationDecisionTest.kt @@ -1,34 +1,49 @@ package com.mg4.control.automation import com.mg4.control.automation.AutomationDecision.Outcome +import com.mg4.control.automation.AutomationSettings.Direction import org.junit.Assert.assertEquals import org.junit.Test class AutomationDecisionTest { @Test fun `desactive - non applicable`() { - assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(false, 30f, 25, true)) + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(false, 30f, 25, Direction.BELOW, true)) } @Test fun `temp illisible - non applicable`() { - assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, null, 25, true)) - assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, Float.NaN, 25, true)) + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, null, 25, Direction.BELOW, true)) + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, Float.NaN, 25, Direction.ABOVE, true)) } @Test fun `profil absent - non applicable`() { - assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 30f, 25, false)) + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 30f, 25, Direction.BELOW, false)) } - @Test fun `sous le seuil - applique`() { - assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 24.9f, 25, true)) + // ── Direction BELOW : déclenche quand il fait ≤ seuil ───────────────────── + @Test fun `below - sous le seuil applique`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 24.9f, 25, Direction.BELOW, true)) } - @Test fun `au seuil (borne incluse) - applique`() { - assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 25f, 25, true)) + @Test fun `below - au seuil applique (borne incluse)`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 25f, 25, Direction.BELOW, true)) } - @Test fun `au dessus du seuil - non applicable`() { - assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 31.5f, 25, true)) + @Test fun `below - au dessus du seuil non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 31.5f, 25, Direction.BELOW, true)) + } + + // ── Direction ABOVE : déclenche quand il fait ≥ seuil ───────────────────── + @Test fun `above - au dessus du seuil applique`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 26.5f, 25, Direction.ABOVE, true)) + } + + @Test fun `above - au seuil applique (borne incluse)`() { + assertEquals(Outcome.APPLY, AutomationDecision.evaluate(true, 25f, 25, Direction.ABOVE, true)) + } + + @Test fun `above - sous le seuil non applicable`() { + assertEquals(Outcome.NOT_APPLICABLE, AutomationDecision.evaluate(true, 24.9f, 25, Direction.ABOVE, true)) } @Test fun `clampTemp borne 0 a 60, defaut si null`() {