Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions app/src/main/java/com/mg4/control/automation/AutomationDecision.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
17 changes: 15 additions & 2 deletions app/src/main/java/com/mg4/control/automation/AutomationSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}'")
Expand All @@ -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") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,17 +38,19 @@ 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(
context: Context,
profile: DrivingProfile,
threshold: Int,
currentTemp: Float,
direction: AutomationSettings.Direction,
onConfirmed: () -> Unit,
onDeclined: () -> Unit
) {
Expand All @@ -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<TextView>(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
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/com/mg4/control/ui/AutomationFragment.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<MaterialButton>(R.id.btn_dir_below)
val btnDirAbove = view.findViewById<MaterialButton>(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)
}

Expand Down
54 changes: 54 additions & 0 deletions app/src/main/res/layout/fragment_automation.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
Expand Down Expand Up @@ -42,6 +43,59 @@
android:orientation="vertical"
android:visibility="gone">

<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/dash_border"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp" />

<!-- Sens du déclenchement (inférieure / supérieure au seuil) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/automation_direction_label"
android:textColor="@color/text_secondary"
android:textSize="13sp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">

<com.google.android.material.button.MaterialButton
android:id="@+id/btn_dir_below"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:text="@string/automation_dir_below"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textAllCaps="true"
app:backgroundTint="@color/dash_btn"
app:strokeWidth="1dp"
app:strokeColor="@color/dash_border"
app:cornerRadius="8dp" />

<com.google.android.material.button.MaterialButton
android:id="@+id/btn_dir_above"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:text="@string/automation_dir_above"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textAllCaps="true"
app:backgroundTint="@color/dash_btn"
app:strokeWidth="1dp"
app:strokeColor="@color/dash_border"
app:cornerRadius="8dp" />

</LinearLayout>

<View
android:layout_width="match_parent"
android:layout_height="1dp"
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,8 @@
<string name="automation_confirm_msg">Die Außentemperatur liegt unter %1$d°C\n(%2$s°C aktuelle Temperatur)\nProfil „%3$s“ anwenden?</string>
<string name="automation_confirm_yes">JA</string>
<string name="automation_confirm_no">NEIN</string>
<string name="automation_direction_label">Auslösen, wenn die Außentemperatur ist:</string>
<string name="automation_dir_below">Unter</string>
<string name="automation_dir_above">Über</string>
<string name="automation_confirm_msg_above">Die Außentemperatur liegt über %1$d°C\n(%2$s°C aktuelle Temperatur)\nProfil „%3$s“ anwenden?</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values-en/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,8 @@
<string name="automation_confirm_msg">The outside temperature is below %1$d°C\n(%2$s°C current temperature)\nApply profile “%3$s”?</string>
<string name="automation_confirm_yes">YES</string>
<string name="automation_confirm_no">NO</string>
<string name="automation_direction_label">Trigger when the outside temperature is:</string>
<string name="automation_dir_below">Below</string>
<string name="automation_dir_above">Above</string>
<string name="automation_confirm_msg_above">The outside temperature is above %1$d°C\n(%2$s°C current temperature)\nApply profile “%3$s”?</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,8 @@
<string name="automation_confirm_msg">La temperatura exterior está por debajo de %1$d°C\n(%2$s°C temperatura actual)\n¿Aplicar el perfil «%3$s»?</string>
<string name="automation_confirm_yes">SÍ</string>
<string name="automation_confirm_no">NO</string>
<string name="automation_direction_label">Activar cuando la temperatura exterior sea:</string>
<string name="automation_dir_below">Inferior a</string>
<string name="automation_dir_above">Superior a</string>
<string name="automation_confirm_msg_above">La temperatura exterior está por encima de %1$d°C\n(%2$s°C temperatura actual)\n¿Aplicar el perfil «%3$s»?</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,8 @@
<string name="automation_confirm_msg">La temperatura esterna è sotto i %1$d°C\n(%2$s°C temperatura attuale)\nApplicare il profilo «%3$s»?</string>
<string name="automation_confirm_yes">SÌ</string>
<string name="automation_confirm_no">NO</string>
<string name="automation_direction_label">Attiva quando la temperatura esterna è:</string>
<string name="automation_dir_below">Inferiore a</string>
<string name="automation_dir_above">Superiore a</string>
<string name="automation_confirm_msg_above">La temperatura esterna è sopra i %1$d°C\n(%2$s°C temperatura attuale)\nApplicare il profilo «%3$s»?</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values-pt/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,8 @@
<string name="automation_confirm_msg">A temperatura exterior está abaixo de %1$d°C\n(%2$s°C temperatura atual)\nAplicar o perfil «%3$s»?</string>
<string name="automation_confirm_yes">SIM</string>
<string name="automation_confirm_no">NÃO</string>
<string name="automation_direction_label">Acionar quando a temperatura exterior for:</string>
<string name="automation_dir_below">Inferior a</string>
<string name="automation_dir_above">Superior a</string>
<string name="automation_confirm_msg_above">A temperatura exterior está acima de %1$d°C\n(%2$s°C temperatura atual)\nAplicar o perfil «%3$s»?</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,8 @@
<string name="automation_confirm_msg">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 » ?</string>
<string name="automation_confirm_yes">OUI</string>
<string name="automation_confirm_no">NON</string>
<string name="automation_direction_label">Déclencher si la température extérieure est :</string>
<string name="automation_dir_below">Inférieure à</string>
<string name="automation_dir_above">Supérieure à</string>
<string name="automation_confirm_msg_above">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 » ?</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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`() {
Expand Down
Loading