Skip to content

Commit 4de138e

Browse files
committed
add airflow configuration for HVAC
1 parent 3d33ab6 commit 4de138e

19 files changed

Lines changed: 589 additions & 13 deletions

File tree

‎app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt‎

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import java.util.concurrent.ConcurrentHashMap
1818
import java.lang.reflect.InvocationHandler
1919
import java.lang.reflect.Proxy
2020
import com.mg4.control.debug.AppLogger
21+
import com.mg4.control.model.AirFlow
2122
import com.mg4.control.model.DriveMode
2223
import com.mg4.control.model.RegenLevel
2324
import com.mg4.control.util.FirmwareInfo
@@ -5128,6 +5129,13 @@ object MG4Hardware {
51285129

51295130
/** Mode de recirculation — seule source fiable (l'OEM getLoopMode n'est pas vérifié). */
51305131
private const val PROP_HVAC_LOOP_MODE = 0x15402507
5132+
/**
5133+
* Sens de l'air — `HVAC_BLOWER_DIRECTION`, échelle 0–7 décodée des firmwares (voir [AirFlow]).
5134+
* Les deux familles écrivent ICI, valeur brute : ce n'est pas une bascule.
5135+
*/
5136+
private const val PROP_HVAC_BLOWER_DIRECTION = 0x1540250e
5137+
/** Propriété AOSP homonyme (masque de bits, autre échelle) — lue par la sonde seulement. */
5138+
private const val PROP_AOSP_FAN_DIRECTION = 0x15400501
51315139
/** État A/C — encodage mesuré sur véhicule : 1 = allumé, 0 = éteint. */
51325140
private const val PROP_HVAC_AC_ON = 0x15402500
51335141

@@ -5179,7 +5187,9 @@ object MG4Hardware {
51795187
val autoOn: Boolean?,
51805188
val loopMode: Int?,
51815189
val defrostFront: Boolean?,
5182-
val defrostRear: Boolean?
5190+
val defrostRear: Boolean?,
5191+
/** Sens de l'air, valeur brute 0–7 ([AirFlow]) ; null si illisible. */
5192+
val airFlow: Int?
51835193
)
51845194

51855195
/**
@@ -5208,8 +5218,11 @@ object MG4Hardware {
52085218
* n'aboutir qu'après la création de la page (bind asynchrone), auquel cas le prochain
52095219
* rafraîchissement périodique remplit l'écran tout seul.
52105220
*/
5211-
fun getClimateState(): ClimateState? {
5212-
if (isClimateA9()) return getClimateStateA9()
5221+
fun getClimateState(): ClimateState? =
5222+
(if (isClimateA9()) getClimateStateA9() else getClimateStateOldSdk())
5223+
?.also { traceAirFlowChange(it) }
5224+
5225+
private fun getClimateStateOldSdk(): ClimateState? {
52135226
if (sAirCondition == null) sAppContext?.let { initAirCondition(it) }
52145227
if (sAirCondition == null) return null
52155228
// Bornes lues sur la voiture (jamais codées en dur) ; repli sur des valeurs sûres.
@@ -5230,7 +5243,11 @@ object MG4Hardware {
52305243
// Lu via la PROPRIÉTÉ (0/1/2 mesurés sur véhicule), pas via l'OEM getLoopMode.
52315244
loopMode = getIntPropertyHvac(PROP_HVAC_LOOP_MODE, AREA_HVAC).takeIf { it >= 0 },
52325245
defrostFront = acInt("getFrontWindowDefroster")?.takeIf { it >= 0 }?.let { it == 1 },
5233-
defrostRear = acInt("getBackWindowDefroster")?.takeIf { it >= 0 }?.let { it == 1 }
5246+
defrostRear = acInt("getBackWindowDefroster")?.takeIf { it >= 0 }?.let { it == 1 },
5247+
// Propriété d'abord (même voie que le recyclage) ; le getter OEM lit la même chose,
5248+
// en repli si le CarHvacManager n'est pas encore lié.
5249+
airFlow = getIntPropertyHvac(PROP_HVAC_BLOWER_DIRECTION, AREA_HVAC).takeIf { it in 0..7 }
5250+
?: acInt("getBlowerDirectionMode")?.takeIf { it in 0..7 }
52345251
)
52355252
}
52365253

@@ -5273,7 +5290,8 @@ object MG4Hardware {
52735290
autoOn = a9Get("getAutoStatus") as? Boolean,
52745291
loopMode = (a9Get("getAirCirculationStatus") as? Int)?.takeIf { it >= 0 },
52755292
defrostFront = a9Get("getFrontDefrostStatus") as? Boolean,
5276-
defrostRear = a9Get("getRearDefrostStatus") as? Boolean
5293+
defrostRear = a9Get("getRearDefrostStatus") as? Boolean,
5294+
airFlow = (a9Get("getFanDirection") as? Int)?.takeIf { it in 0..7 }
52775295
)
52785296
}
52795297

@@ -5359,18 +5377,97 @@ object MG4Hardware {
53595377
}
53605378

53615379
fun setClimateDefrostFront(on: Boolean): Boolean =
5362-
if (isClimateA9())
5380+
(if (isClimateA9())
53635381
a9CycleTo("defrostFront", "getFrontDefrostStatus", if (on) 1 else 0, 2, "switchFrontDefrostStatus")
53645382
else
53655383
acCall(if (on) "openFrontWindowDefroster" else "closeFrontWindowDefroster")
5366-
.also { climLog("defrostFront=$on", it) }
5384+
.also { climLog("defrostFront=$on", it) })
5385+
.also { scheduleAirFlowProbe("après dégivrage AV=$on") }
53675386

53685387
fun setClimateDefrostRear(on: Boolean): Boolean =
5369-
if (isClimateA9())
5388+
(if (isClimateA9())
53705389
a9CycleTo("defrostRear", "getRearDefrostStatus", if (on) 1 else 0, 2, "switchRearDefrostStatus")
53715390
else
53725391
acCall(if (on) "openBackWindowDefroster" else "closeBackWindowDefroster")
5373-
.also { climLog("defrostRear=$on", it) }
5392+
.also { climLog("defrostRear=$on", it) })
5393+
.also { scheduleAirFlowProbe("après dégivrage AR=$on") }
5394+
5395+
/**
5396+
* Sens de l'air — écriture DIRECTE de la valeur (0–6, voir [AirFlow]), pas une bascule.
5397+
*
5398+
* Vérifié dans le code des services d'origine : l'ancien SDK (`setBlowerDirectionMode`) et A9
5399+
* (`setFanDirection`) écrivent tous deux la valeur brute dans `HVAC_BLOWER_DIRECTION`. Le
5400+
* service ancien SDK refuse hors de 0–7 ; celui d'A9 ignore une 2e écriture dans les 300 ms
5401+
* (anti-rebond du `HvacDataStore`) — sans conséquence pour des appuis au doigt.
5402+
*
5403+
* ⚠️ Pas encore validé sur véhicule : ce que 3, 5 et 6 font du dégivrage avant, et si 5/6
5404+
* sont appliqués. Les sondes MG4_AIR encadrent chaque écriture pour le mesurer.
5405+
*/
5406+
fun setClimateAirFlow(direction: Int): Boolean {
5407+
if (direction !in 0..6) return false
5408+
probeAirFlow("avant écriture $direction")
5409+
val ok = if (isClimateA9())
5410+
a9Set("setFanDirection", direction).also { climLog("A9 airFlow=$direction", it) }
5411+
else
5412+
acSet("setBlowerDirectionMode", direction).also { climLog("airFlow=$direction", it) }
5413+
scheduleAirFlowProbe("après écriture $direction")
5414+
return ok
5415+
}
5416+
5417+
// ── SONDE TEMPORAIRE sens de l'air ────────────────────────────────────────
5418+
// À retirer une fois validés sur véhicule : le lien dégivrage AV ⇔ valeur 4, l'effet de
5419+
// 3/5/6 sur le dégivrage, et l'application réelle de 5 et 6. Tag dédié pour filtrer.
5420+
private const val AIR_TAG = "MG4_AIR"
5421+
5422+
/** Dernier instantané tracé : on ne journalise qu'un CHANGEMENT, pas chaque rafraîchissement. */
5423+
@Volatile private var sLastAirTrace: String? = null
5424+
5425+
/**
5426+
* Relevé complet des sources liées au sens de l'air. Lit les DEUX voies (propriété et getter
5427+
* du SDK) et la propriété AOSP homonyme : c'est leur comparaison qui dira laquelle est fiable.
5428+
*/
5429+
fun probeAirFlow(origin: String) {
5430+
try {
5431+
val prop = getIntPropertyHvac(PROP_HVAC_BLOWER_DIRECTION, AREA_HVAC)
5432+
val aosp = getIntPropertyHvac(PROP_AOSP_FAN_DIRECTION, AREA_HVAC)
5433+
val msg = if (isClimateA9()) {
5434+
"A9 getFanDirection=${a9Get("getFanDirection")} prop0x1540250e=$prop aosp0x15400501=$aosp " +
5435+
"dégAV=${a9Get("getFrontDefrostStatus")} dégAR=${a9Get("getRearDefrostStatus")} " +
5436+
"clim=${a9Get("getHvacPowerStatus")} ventil=${a9Get("getFanSpeed")}"
5437+
} else {
5438+
"prop0x1540250e=$prop getBlowerDirectionMode=${acInt("getBlowerDirectionMode")} aosp0x15400501=$aosp " +
5439+
"dégAV=${acInt("getFrontWindowDefroster")} dégAR=${acInt("getBackWindowDefroster")} " +
5440+
"clim=${acInt("getHvacPowerStatus")} ventil=${acInt("getAirVolumeLevel")} A/C=${acInt("getAcSwitch")}"
5441+
}
5442+
AppLogger.i(AIR_TAG, "SONDE [$origin] $msg")
5443+
} catch (e: Exception) {
5444+
AppLogger.w(AIR_TAG, "SONDE [$origin] échec : ${e.message}")
5445+
}
5446+
}
5447+
5448+
/** Deux relevés différés : le véhicule met un instant à propager, et le dégivrage peut suivre. */
5449+
private fun scheduleAirFlowProbe(origin: String) {
5450+
kotlin.concurrent.thread(name = "mg4-air-probe", isDaemon = true) {
5451+
try {
5452+
Thread.sleep(1_500)
5453+
probeAirFlow("$origin +1,5 s")
5454+
Thread.sleep(3_000)
5455+
probeAirFlow("$origin +4,5 s")
5456+
} catch (_: InterruptedException) {}
5457+
}
5458+
}
5459+
5460+
/**
5461+
* Trace tout changement du sens de l'air ou des dégivrages vu par les rafraîchissements du
5462+
* Dashboard — y compris ceux faits depuis l'écran d'ORIGINE, qui ne passent pas par nous.
5463+
*/
5464+
private fun traceAirFlowChange(s: ClimateState) {
5465+
val snap = "air=${s.airFlow} dégAV=${s.defrostFront} dégAR=${s.defrostRear} clim=${s.powerOn}"
5466+
if (snap == sLastAirTrace) return
5467+
val avant = sLastAirTrace
5468+
sLastAirTrace = snap
5469+
AppLogger.i(AIR_TAG, "changement : ${avant ?: "(premier relevé)"} → $snap")
5470+
}
53745471

53755472
/**
53765473
* Applique un préréglage complet de climatisation (automatisation température).
@@ -5404,7 +5501,8 @@ object MG4Hardware {
54045501
fanLevel: Int,
54055502
defrostFront: Boolean?,
54065503
defrostRear: Boolean?,
5407-
loopMode: Int?
5504+
loopMode: Int?,
5505+
airFlow: Int? = null
54085506
): Boolean {
54095507
val state = getClimateState() ?: run {
54105508
AppLogger.w(CLIM_TAG, "Profil : état clim illisible → abandon")
@@ -5446,10 +5544,22 @@ object MG4Hardware {
54465544
ok = setClimateLoopMode(mode) && ok
54475545
}
54485546

5547+
// Ligne « Air » (boutons cumulables), APRÈS les anciennes lignes de dégivrage, qui
5548+
// coexistent le temps de valider le lien dégivrage ⇔ pare-brise : si les deux portent
5549+
// sur la lunette arrière, c'est la ligne Air qui l'emporte. Hors « Inchangé », le profil
5550+
// applique ce qui est affiché : lunette arrière non cochée = dégivrage arrière ÉTEINT.
5551+
if (airFlow != null) {
5552+
AirFlow.directionForMask(airFlow)?.let { ok = setClimateAirFlow(it) && ok }
5553+
if (state.defrostRear != null) {
5554+
ok = setClimateDefrostRear(airFlow and AirFlow.REAR_DEFROST != 0) && ok
5555+
}
5556+
}
5557+
54495558
AppLogger.i(CLIM_TAG, "Profil : clim=ON A/C=$ac consigne=$targetTemp " +
54505559
(if (autoMode) "ventilation=AUTO" else "vent=$fanLevel") +
54515560
" dégAV=${defrostFront ?: "inchangé"} dégAR=${defrostRear ?: "inchangé"} " +
5452-
"recyclage=${loopMode ?: "inchangé"} → ok=$ok")
5561+
"recyclage=${loopMode ?: "inchangé"} " +
5562+
"air=${airFlow?.let { "boutons=$it valeur=${AirFlow.directionForMask(it)}" } ?: "inchangé"} → ok=$ok")
54535563
return ok
54545564
}
54555565

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.mg4.control.model
2+
3+
/**
4+
* Sens de l'air : correspondance entre les boutons cumulables de l'écran (Visage, Pieds,
5+
* Pare-brise AV) et la valeur de la propriété véhicule `HVAC_BLOWER_DIRECTION` (0x1540250e).
6+
*
7+
* L'échelle a été décodée des firmwares (tableau d'icônes `res_blow_on` de SystemUI, identique
8+
* sur SWI69 et SWI131) : les deux familles écrivent cette même propriété avec cette même valeur,
9+
* l'ancien SDK via `setBlowerDirectionMode`, A9 via `setFanDirection`. Ce n'est PAS un masque de
10+
* bits — « visage + pare-brise » vaut 6 — d'où une table explicite.
11+
*
12+
* | Valeur | Visage | Pieds | Pare-brise |
13+
* |--------|:------:|:-----:|:----------:|
14+
* | 0 | ✓ | | |
15+
* | 1 | ✓ | ✓ | |
16+
* | 2 | | ✓ | |
17+
* | 3 | | ✓ | ✓ |
18+
* | 4 | | | ✓ |
19+
* | 5 | ✓ | ✓ | ✓ |
20+
* | 6 | ✓ | | ✓ |
21+
*
22+
* 7 (« aucun » dans le firmware) n'est jamais écrit.
23+
*/
24+
object AirFlow {
25+
26+
// ── Bits du profil (DrivingProfile.hvacAirFlow) ──────────────────────────
27+
// Le profil range les BOUTONS et non la valeur : c'est ce que l'utilisateur a coché, et la
28+
// lunette arrière, absente de l'échelle, doit y tenir aussi.
29+
const val FACE = 1
30+
const val FEET = 2
31+
const val WINDSHIELD = 4
32+
const val REAR_DEFROST = 8
33+
34+
data class Parts(val face: Boolean, val feet: Boolean, val windshield: Boolean)
35+
36+
/** Valeur à écrire pour cette combinaison, ou null si aucun des trois n'est choisi. */
37+
fun directionFor(face: Boolean, feet: Boolean, windshield: Boolean): Int? = when {
38+
face && feet && windshield -> 5
39+
face && windshield -> 6
40+
face && feet -> 1
41+
feet && windshield -> 3
42+
face -> 0
43+
feet -> 2
44+
windshield -> 4
45+
else -> null
46+
}
47+
48+
/** Boutons allumés pour une valeur lue ; null hors échelle (7, illisible, inconnue). */
49+
fun partsOf(direction: Int): Parts? = when (direction) {
50+
0 -> Parts(face = true, feet = false, windshield = false)
51+
1 -> Parts(face = true, feet = true, windshield = false)
52+
2 -> Parts(face = false, feet = true, windshield = false)
53+
3 -> Parts(face = false, feet = true, windshield = true)
54+
4 -> Parts(face = false, feet = false, windshield = true)
55+
5 -> Parts(face = true, feet = true, windshield = true)
56+
6 -> Parts(face = true, feet = false, windshield = true)
57+
else -> null
58+
}
59+
60+
/** Valeur à écrire pour les boutons cochés d'un profil ; la lunette arrière n'y compte pas. */
61+
fun directionForMask(mask: Int): Int? = directionFor(
62+
face = mask and FACE != 0,
63+
feet = mask and FEET != 0,
64+
windshield = mask and WINDSHIELD != 0
65+
)
66+
}

‎app/src/main/java/com/mg4/control/model/DrivingProfile.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ data class DrivingProfile(
9090
val hvacDefrostRear: Boolean? = null,
9191
/** `null` = inchangé, sinon 0=Intérieur, 1=Extérieur, 2=Auto. */
9292
val hvacLoopMode: Int? = null,
93+
/**
94+
* Ligne « Air » : boutons cochés, en bits [AirFlow.FACE] / [AirFlow.FEET] /
95+
* [AirFlow.WINDSHIELD] / [AirFlow.REAR_DEFROST]. `null` = inchangé — c'est aussi ce que Gson
96+
* rend pour un profil enregistré avant l'ajout du champ.
97+
*/
98+
val hvacAirFlow: Int? = null,
9399
// [BT-PROFILES] MAC de l'appareil Bluetooth associé à ce profil (null = aucun)
94100
val btDeviceMac: String? = null
95101
) {

‎app/src/main/java/com/mg4/control/profile/ProfileApplier.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ object ProfileApplier {
144144
fanLevel = profile.hvacFan,
145145
defrostFront = profile.hvacDefrostFront,
146146
defrostRear = profile.hvacDefrostRear,
147-
loopMode = profile.hvacLoopMode
147+
loopMode = profile.hvacLoopMode,
148+
airFlow = profile.hvacAirFlow
148149
)
149150
AppLogger.i(TAG, " Climatisation du profil appliquée → $climOk")
150151
}

0 commit comments

Comments
 (0)