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
11 changes: 11 additions & 0 deletions app/src/main/java/com/mg4/control/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ class MainActivity : AppCompatActivity() {
// ── Boutons de navigation dans la top-bar ─────────────────────────────────

private fun setupNavButtons() {
val btnAutomation = findViewById<MaterialButton>(R.id.btn_nav_automation)
val btnAudio = findViewById<MaterialButton>(R.id.btn_nav_audio)
val btnShortcuts = findViewById<MaterialButton>(R.id.btn_nav_shortcuts)
val btnProfiles = findViewById<MaterialButton>(R.id.btn_nav_profiles)
Expand All @@ -308,6 +309,13 @@ class MainActivity : AppCompatActivity() {
btnAudio.visibility = View.GONE
}

btnAutomation.setOnClickListener {
when (navController.currentDestination?.id) {
R.id.automationFragment -> navController.popBackStack(R.id.dashboardFragment, false)
else -> navController.navigate(R.id.automationFragment)
}
}

btnShortcuts.setOnClickListener {
when (navController.currentDestination?.id) {
R.id.shortcutsFragment -> navController.popBackStack(R.id.dashboardFragment, false)
Expand All @@ -332,6 +340,9 @@ class MainActivity : AppCompatActivity() {
navController.addOnDestinationChangedListener { _, destination, _ ->
val accent = getColor(R.color.dash_accent_dim)
val inactive = getColor(R.color.dash_btn)
btnAutomation.backgroundTintList = android.content.res.ColorStateList.valueOf(
if (destination.id == R.id.automationFragment) accent else inactive
)
btnAudio.backgroundTintList = android.content.res.ColorStateList.valueOf(
if (destination.id == R.id.audioFragment) accent else inactive
)
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/com/mg4/control/automation/AutomationDecision.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.mg4.control.automation

/** Décision pure de l'automatisation température (testable sans Android). */
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).
* Sinon NOT_APPLICABLE.
*/
fun evaluate(enabled: Boolean, temp: Float?, threshold: Int, profileExists: Boolean): Outcome = when {
!enabled -> Outcome.NOT_APPLICABLE
temp == null || temp.isNaN() -> Outcome.NOT_APPLICABLE
!profileExists -> Outcome.NOT_APPLICABLE
temp <= threshold.toFloat() -> Outcome.APPLY
else -> Outcome.NOT_APPLICABLE
}
}
37 changes: 37 additions & 0 deletions app/src/main/java/com/mg4/control/automation/AutomationSettings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.mg4.control.automation

import android.content.Context

/** Clés + defaults de l'automatisation température, partagés entre l'UI et le service. */
object AutomationSettings {

const val PREFS = "mg4_settings"
const val KEY_ENABLED = "automation_temp_enabled"
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 DEFAULT_THRESHOLD = 25
const val MIN_TEMP = 0
const val MAX_TEMP = 60

data class Config(
val enabled: Boolean,
val threshold: Int,
val profileId: String,
val autoExecute: Boolean
)

fun read(context: Context): Config {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return Config(
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)
)
}

/** 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)
}
123 changes: 123 additions & 0 deletions app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ object MG4Hardware {
initKatman5Swi69(context)
else
initKatman5(context)
// Température (sonde Diagnostic) — bind async du service clim SAIC ; no-op si absent.
initAirCondition(context)
if (sVehicleBinder != null)
AppLogger.i(TAG, " ✓ Katman2: vehiclesetting binder OK")
else
Expand Down Expand Up @@ -3327,6 +3329,127 @@ object MG4Hardware {
else sDoorReadLast.entries.joinToString { "0x${it.key.toString(16)}=${it.value}" })
}

// ── Sonde température (bouton Diagnostic) ─────────────────────────────────
private const val TEMP_TAG = "MG4_TEMP"
// Source RÉELLE de la temp (décompilé SystemUI SWI133) : service clim SAIC, PAS une propriété CPM.
private const val AIRCON_CLASS = "com.saicmotor.sdk.vehiclesettings.manager.AirConditionManager"
@Volatile private var sAirCondition: Any? = null

// Voie CPM (secondaire). ⚠ SAIC a INVERSÉ current/set (0x…502/503) par rapport à l'AAOS.
private const val PROP_ENV_OUTSIDE_TEMP = 0x11600703 // ENV_OUTSIDE_TEMPERATURE (AAOS std — renvoie 0 ici)
private const val PROP_HVAC_TEMP_OUTCAR = 0x15602511 // HVAC_TEMPERATURE_OUTCAR (vendor SAIC = temp extérieure)
private const val PROP_HVAC_AMBIENT_TEMP = 0x1560252a // HVAC_AMBIENT_TEMPERATURE (vendor SAIC)
private const val PROP_HVAC_TEMP_CURRENT = 0x15600502 // HVAC_TEMPERATURE_CURRENT (SAIC — inversé vs AAOS)
private val TEMP_HVAC_AREAS = intArrayOf(0x1, 0x2, 0x4, AREA_HVAC, AREA_GLOBAL, 0)

private fun fmtTemp(v: Float?): String = when {
v == null || v.isNaN() -> "illisible"
v <= -1000f -> "n/c(${"%.0f".format(v)})" // sentinelle SAIC -10000 = service non connecté
else -> "%.1f".format(v)
}

/** Lit un getter float sans argument sur le manager clim SAIC (réflexion). */
private fun acFloat(name: String): Float? {
val ac = sAirCondition ?: return null
return try { ac.javaClass.getMethod(name).invoke(ac) as? Float } catch (_: Exception) { null }
}
/** Lit un getter int sans argument sur le manager clim SAIC (réflexion). */
private fun acInt(name: String): Int? {
val ac = sAirCondition ?: return null
return try { ac.javaClass.getMethod(name).invoke(ac) as? Int } catch (_: Exception) { null }
}

/**
* Bind (async) au service clim SAIC — `AirConditionManager`, même SDK que VehicleConditionManager
* (Katman5). C'est la VRAIE source de la temp extérieure (`getOutCarTemp`), pas une propriété CPM.
* No-op silencieux si le SDK est absent (ex. A9, autre package). Idempotent.
*/
private fun initAirCondition(context: Context) {
if (sAirCondition != null) return
val launcherCtx = listOf(LAUNCHER68_PKG, LAUNCHER69_PKG).firstNotNullOfOrNull { pkg ->
try {
context.createPackageContext(
pkg,
android.content.Context.CONTEXT_INCLUDE_CODE or android.content.Context.CONTEXT_IGNORE_SECURITY
)
} catch (_: Exception) { null }
} ?: return

val acClass = try {
launcherCtx.classLoader.loadClass(AIRCON_CLASS)
} catch (e: Exception) {
AppLogger.d(TEMP_TAG, "AirConditionManager absent: ${e.message}")
return
}
fun singleton(): Any? = try { acClass.getMethod("getInstance").invoke(null) } catch (_: Exception) { null }

val initMethod = acClass.methods.firstOrNull { m ->
m.name == "init" && m.parameterCount == 2 &&
Context::class.java.isAssignableFrom(m.parameterTypes[0])
}
if (initMethod != null) {
val listenerType = initMethod.parameterTypes[1]
val listenerArg: Any? = if (listenerType.isInterface) try {
java.lang.reflect.Proxy.newProxyInstance(
listenerType.classLoader, arrayOf(listenerType)
) { _, method, _ ->
if (method.name == "onServiceConnected") {
AppLogger.i(TEMP_TAG, "AirCondition: onServiceConnected ✓")
sAirCondition = singleton()
}
null
}
} catch (_: Exception) { null } else null
try {
initMethod.invoke(null, context.applicationContext, listenerArg)
AppLogger.i(TEMP_TAG, "AirCondition.init() appelé")
} catch (e: Exception) {
AppLogger.w(TEMP_TAG, "AirCondition.init() erreur: ${e.message}")
}
}
// Handle immédiat ; la valeur sera valide dès que le service est connecté (async).
if (sAirCondition == null) sAirCondition = singleton()
}

/**
* Sonde du bouton Diagnostic (lecture seule). Voie principale = service clim SAIC
* (`getOutCarTemp`, ce que fait l'OEM). Voie CPM = secondaire, teste les IDs vendor.
*/
fun runTemperatureDiag() {
AppLogger.i(TEMP_TAG, "── DIAG température ──")
sAppContext?.let { initAirCondition(it) } // au cas où l'init au démarrage n'a pas abouti

// Voie OEM (la bonne).
if (sAirCondition == null) {
AppLogger.i(TEMP_TAG, "AirConditionManager indisponible (SDK non chargé) — voir voie CPM")
} else {
AppLogger.i(TEMP_TAG, "OEM getOutCarTemp=${fmtTemp(acFloat("getOutCarTemp"))} " +
"drvSet=${acInt("getDrvTemp") ?: "?"} psgSet=${acInt("getPsgTemp") ?: "?"}")
}

// Voie CPM secondaire : IDs vendor SAIC (au cas où certains soient lisibles en direct).
AppLogger.i(TEMP_TAG, "CPM EXTstd(0x11600703)=${fmtTemp(getFloatPropertyCPM(PROP_ENV_OUTSIDE_TEMP, AREA_GLOBAL))} " +
"OUTCAR(0x15602511)=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, AREA_GLOBAL))} " +
"AMBIENT(0x1560252a)=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_AMBIENT_TEMP, AREA_GLOBAL))}")
for (area in TEMP_HVAC_AREAS) {
val a = "0x${Integer.toHexString(area)}"
AppLogger.i(TEMP_TAG, "CPM area=$a OUTCAR=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, area))} " +
"AMBIENT=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_AMBIENT_TEMP, area))} " +
"CURRENT=${fmtTemp(getFloatPropertyCPM(PROP_HVAC_TEMP_CURRENT, area))}")
}
}

/**
* Température extérieure en °C, ou null si illisible. Voie OEM (`getOutCarTemp`) puis
* repli CPM (`HVAC_TEMPERATURE_OUTCAR` @ zone 0x75, validé sur SWI133). Sentinelle SAIC
* (-10000) et NaN => null. Lecture seule.
*/
fun getOutsideTempCelsius(): Float? {
acFloat("getOutCarTemp")?.let { if (!it.isNaN() && it > -1000f) return it }
getFloatPropertyCPM(PROP_HVAC_TEMP_OUTCAR, AREA_HVAC)?.let { if (!it.isNaN() && it > -1000f) return it }
return null
}

/** Connexion (async) à l'API Car AOSP → CarPropertyManager ("property") ET CarDoorLockManager
* ("doorlock"). Selon le firmware, la porte est exposée par l'un ou l'autre → on lit via les deux. */
private fun connectCarProperty() {
Expand Down
69 changes: 50 additions & 19 deletions app/src/main/java/com/mg4/control/hardware/VehicleWriteGate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import com.mg4.control.R
import com.mg4.control.debug.AppLogger

/**
* [T-904] Politique décidée : une écriture de réglage véhicule n'est autorisée QU'À L'ARRÊT.
* [T-904] Verrou d'écriture véhicule configurable (Réglages → « Sécurité conduite »).
*
* Changer l'AEB, l'ELK, l'ACC/TJA ou le mode de conduite en roulant modifie le comportement
* du véhicule sous le conducteur. La règle est donc : 0 km/h, sinon refus — et refus AUSSI
* quand la vitesse est illisible (fail closed), parce qu'une vitesse inconnue peut être
* n'importe quelle vitesse.
* OFF par défaut : aucune restriction. Quand l'utilisateur l'active, une écriture de réglage
* de conduite (AEB, ELK, ACC/TJA, mode de conduite…) n'est autorisée que jusqu'à la vitesse
* maximale choisie (bornes incluses) ; au-dessus, refus. La vitesse illisible reste un refus
* (fail closed), une vitesse inconnue pouvant être n'importe quelle vitesse.
*
* Le confort (sièges/volant chauffants, via CarHvacManager) n'est PAS concerné : ces
* écritures ne changent pas le comportement routier.
Expand All @@ -22,6 +22,15 @@ object VehicleWriteGate {

private const val TAG = "MG4_GATE"

/** Store partagé avec SettingsFragment. */
const val PREFS_NAME = "mg4_settings"
/** Clé bool : sécurité activée. Défaut false (aucune restriction). */
const val KEY_ENABLED = "safety_speed_gate_enabled"
/** Clé int : vitesse max (km/h) jusqu'à laquelle les écritures passent. Défaut 0. */
const val KEY_MAX_KMH = "safety_speed_gate_max_kmh"
/** Vitesse max saisissable. */
const val MAX_SPEED_KMH = 250

/** Anti-spam sur le message utilisateur : un refus par seconde au plus. */
private const val TOAST_THROTTLE_MS = 1_000L

Expand All @@ -38,40 +47,62 @@ object VehicleWriteGate {
}

/**
* Décision pure à partir d'une vitesse en km/h, [speedKmh] à null si illisible.
*
* Une vitesse négative est traitée comme illisible : le VHAL ne produit pas de vitesse
* négative en marche avant, et une valeur aberrante ne doit jamais ouvrir la porte.
* Décision pure. [enabled] false court-circuite tout (aucune restriction).
* Sinon : autorisé jusqu'à [maxKmh] inclus ; vitesse null/NaN/négative = refus
* (fail closed) ; au-dessus du seuil = refus.
*/
fun decide(speedKmh: Float?): Decision = when {
fun decide(speedKmh: Float?, enabled: Boolean, maxKmh: Int): Decision = when {
!enabled -> Decision.ALLOWED
speedKmh == null || speedKmh.isNaN() -> Decision.REFUSED_UNKNOWN_SPEED
speedKmh < 0f -> Decision.REFUSED_UNKNOWN_SPEED
speedKmh == 0f -> Decision.ALLOWED
speedKmh <= maxKmh.toFloat() -> Decision.ALLOWED
else -> Decision.REFUSED_MOVING
}

/** Clampe une saisie utilisateur dans [0, MAX_SPEED_KMH]. null/vide => 0. */
fun clampSpeed(raw: Int?): Int = (raw ?: 0).coerceIn(0, MAX_SPEED_KMH)

/** Décision + seuil courants, lus en direct dans les prefs (sans effet de bord). */
private data class Eval(val decision: Decision, val maxKmh: Int)

private fun evaluate(): Eval {
val context = MG4Hardware.appContext() ?: return Eval(Decision.ALLOWED, 0)
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (!prefs.getBoolean(KEY_ENABLED, false)) return Eval(Decision.ALLOWED, 0)
val maxKmh = prefs.getInt(KEY_MAX_KMH, 0)
return Eval(decide(MG4Hardware.getVehicleSpeedKmh(), enabled = true, maxKmh = maxKmh), maxKmh)
}

/**
* Vrai si l'écriture [operation] est permise maintenant. En cas de refus, journalise et
* prévient l'utilisateur — un refus silencieux ferait croire que le réglage a été pris.
* Vrai si l'écriture [operation] est permise maintenant. Lit la config en direct dans
* les prefs. Sécurité OFF (défaut) ou contexte indisponible => autorisé. En cas de refus,
* journalise et prévient l'utilisateur.
*/
fun allow(operation: String): Boolean {
val decision = decide(MG4Hardware.getVehicleSpeedKmh())
val (decision, maxKmh) = evaluate()
if (decision == Decision.ALLOWED) return true

AppLogger.w(TAG, "Écriture refusée ($operation) : $decision")
notifyUser(decision)
AppLogger.w(TAG, "Écriture refusée ($operation) : $decision (max=$maxKmh km/h)")
notifyUser(decision, maxKmh)
return false
}

private fun notifyUser(decision: Decision) {
/**
* Comme [allow] mais silencieux (ni log ni toast) : pour les appelants qui veulent
* seulement savoir si une écriture passerait maintenant (ex. affichage de l'overlay de
* sélection de profil, qui applique un profil = une écriture).
*/
fun isAllowedNow(): Boolean = evaluate().decision == Decision.ALLOWED

private fun notifyUser(decision: Decision, maxKmh: Int) {
val context: Context = MG4Hardware.appContext() ?: return
val now = System.currentTimeMillis()
if (now - lastToastMs < TOAST_THROTTLE_MS) return
lastToastMs = now

val message = when (decision) {
Decision.REFUSED_MOVING -> R.string.write_refused_moving
Decision.REFUSED_UNKNOWN_SPEED -> R.string.write_refused_unknown_speed
Decision.REFUSED_MOVING -> context.getString(R.string.write_refused_moving, maxKmh)
Decision.REFUSED_UNKNOWN_SPEED -> context.getString(R.string.write_refused_unknown_speed)
Decision.ALLOWED -> return
}
Handler(Looper.getMainLooper()).post {
Expand Down
Loading
Loading