Skip to content

Commit 2af7123

Browse files
committed
Add heated seat/wheel for the pop-up HVAC
1 parent ef85034 commit 2af7123

23 files changed

Lines changed: 385 additions & 27 deletions

File tree

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,37 @@ object HvacPopup {
9595
(reglages.dernierNiveau ?: ((reglages.fanMin + reglages.fanMax) / 2))
9696
.coerceIn(reglages.fanMin, reglages.fanMax)
9797

98+
// ── Sièges chauffants (firmwares équipés seulement) ──────────────────────
99+
private const val NIVEAU_SIEGE_MAX = 3
100+
101+
/**
102+
* Niveau d'un siège chauffant après un appui : éteint → 1 → 2 → 3 → éteint, comme le
103+
* raccourci. null si le niveau est illisible — partir d'un 0 supposé alors que le siège est
104+
* à 3 ferait DESCENDRE le chauffage.
105+
*/
106+
fun niveauSiegeSuivant(actuel: Int?): Int? =
107+
actuel?.let { (it.coerceIn(0, NIVEAU_SIEGE_MAX) + 1) % (NIVEAU_SIEGE_MAX + 1) }
108+
109+
/** Niveau d'un siège en pastilles pleines et vides : « ● ● ○ » pour 2. */
110+
fun pastilles(niveau: Int): String {
111+
val plein = niveau.coerceIn(0, NIVEAU_SIEGE_MAX)
112+
return (1..NIVEAU_SIEGE_MAX).joinToString(" ") { if (it <= plein) "●" else "○" }
113+
}
114+
98115
// ── Taille de la fenêtre ─────────────────────────────────────────────────
99-
private const val LARGEUR_DP = 900f // la carte de overlay_hvac_popup.xml
116+
private const val LARGEUR_DP = 900f // la carte de overlay_hvac_popup.xml, sans chauffages
100117
private const val HAUTEUR_DP = 390f // sa hauteur en texte « Standard »
101118
private const val PART_LARGEUR = 0.63f
102119
private const val PART_HAUTEUR = 0.72f
103120

104121
/**
105122
* Agrandissement à appliquer à la fenêtre, dessinée pour [LARGEUR_DP] × [HAUTEUR_DP] dp, pour
106123
* qu'elle prenne la même part de l'ÉCRAN quelle que soit sa densité : au plus 63 % de la
107-
* largeur et 72 % de la hauteur. Le reste de la hauteur laisse passer la barre d'état, le
108-
* texte « Très grand » et la ligne d'avertissement sans déborder.
124+
* largeur et 72 % de la hauteur. Le reste de la hauteur laisse passer la barre d'état et le
125+
* texte « Très grand » sans déborder.
126+
*
127+
* La colonne des chauffages (firmwares équipés) ajoute 236 dp à la carte sans changer cette
128+
* échelle : la fenêtre occupe alors environ 79 % de la largeur, à taille de boutons égale.
109129
*
110130
* On raisonne en part d'écran, pas en dp fixes : la voiture affiche 1920 × 720 px à 1 px par
111131
* dp (SWI133, mesuré sur une photo le 2026-10-09 — une première fenêtre de 600 dp n'y

‎app/src/main/java/com/mg4/control/service/HvacPopupOverlay.kt‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import com.mg4.control.debug.AppLogger
2525
import com.mg4.control.hardware.MG4Hardware
2626
import com.mg4.control.model.AirFlow
2727
import com.mg4.control.model.HvacPopup
28+
import com.mg4.control.util.FirmwareInfo
2829
import com.mg4.control.util.LocaleHelper
2930
import java.util.Locale
3031
import java.util.concurrent.Executors
@@ -76,6 +77,13 @@ object HvacPopupOverlay {
7677

7778
/** Dernier état lu, corrigé de ce qui vient d'être demandé (voir [surCommande]). */
7879
private var etat: MG4Hardware.ClimateState? = null
80+
81+
/**
82+
* Sièges et volant chauffants ; null sur les firmwares qui n'en ont pas, et la colonne reste
83+
* alors masquée. Un champ à null = illisible : son bouton est grisé.
84+
*/
85+
private data class Chauffages(val siegeGauche: Int?, val siegeDroit: Int?, val volant: Boolean?)
86+
private var chauffages: Chauffages? = null
7987
private var echeance = 0L
8088
private var tick: Runnable? = null
8189

@@ -114,11 +122,12 @@ object HvacPopupOverlay {
114122
ouvertureEnCours = true
115123
vehicule.execute {
116124
val lu = lireEtat()
125+
val chauds = lireChauffages()
117126
handler.post {
118127
ouvertureEnCours = false
119128
// Sans état, il n'y aurait ni valeur à montrer ni base pour « un cran de plus ».
120129
if (lu == null) AppLogger.w(TAG, "Pop-up HVAC non affiché : état clim illisible")
121-
else showOnMain(app, lu)
130+
else showOnMain(app, lu, chauds)
122131
}
123132
}
124133
}
@@ -131,11 +140,30 @@ object HvacPopupOverlay {
131140
null
132141
}
133142

134-
private fun showOnMain(context: Context, lu: MG4Hardware.ClimateState) {
143+
/**
144+
* Même règle que la carte des chauffages du tableau de bord : SWI133, SWI68 et SWI165.
145+
* Ailleurs (finitions sans ces équipements, ou firmware sans commande connue) → null.
146+
*/
147+
private fun lireChauffages(): Chauffages? {
148+
if (!FirmwareInfo.hasHeatFeatures()) return null
149+
return try {
150+
Chauffages(
151+
siegeGauche = MG4Hardware.getSeatHeatLeftOrNull(),
152+
siegeDroit = MG4Hardware.getSeatHeatRightOrNull(),
153+
volant = MG4Hardware.getSteeringHeatOrNull(),
154+
)
155+
} catch (e: Exception) {
156+
AppLogger.w(TAG, "Pop-up HVAC — lecture chauffages : ${e.message}")
157+
Chauffages(null, null, null)
158+
}
159+
}
160+
161+
private fun showOnMain(context: Context, lu: MG4Hardware.ClimateState, chauds: Chauffages?) {
135162
fermer()
136163

137164
val localized = LocaleHelper.applyLocale(context)
138-
// La mise en page est écrite pour une carte de 900 × 390 dp, trop petite sur l'écran de la
165+
// La mise en page est écrite pour une carte de 900 × 390 dp (1136 avec la colonne des
166+
// chauffages, à la même échelle), trop petite sur l'écran de la
139167
// voiture (constaté sur SWI133). Plutôt que de figer d'autres dp, qui ne conviendraient
140168
// qu'à UNE densité, on la gonfle avec une densité corrigée : dp et sp suivent d'un bloc,
141169
// pictos et texte restent nets, et la fenêtre prend la même part de l'écran partout.
@@ -159,6 +187,12 @@ object HvacPopupOverlay {
159187
v.ac.setOnClickListener { basculerAc() }
160188
v.recirculation.forEach { (mode, bouton) -> bouton.setOnClickListener { choisirRecirculation(mode) } }
161189
v.fermer.setOnClickListener { fermer() }
190+
if (chauds != null) {
191+
v.colonneChauffages.visibility = View.VISIBLE
192+
v.siegeGauche.setOnClickListener { cyclerSiege(gauche = true) }
193+
v.siegeDroit.setOnClickListener { cyclerSiege(gauche = false) }
194+
v.volant.setOnClickListener { basculerVolant() }
195+
}
162196
v.repereJoystick(utilisable = KeyCaptureService.isEnabled(context))
163197

164198
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@@ -173,10 +207,11 @@ object HvacPopupOverlay {
173207
gestionnaire = wm
174208
vues = v
175209
etat = lu
210+
chauffages = chauds
176211
overlayView = view
177212
AppLogger.i(TAG, "Pop-up HVAC affiché : ${lu.tempC} °C (${lu.tempMin}..${lu.tempMax}), " +
178213
"ventilation ${lu.fanLevel} (${lu.fanMin}..${lu.fanMax}) AUTO=${lu.autoOn}, A/C=${lu.acOn}, " +
179-
"recirculation ${lu.loopMode}, sens de l'air ${lu.airFlow} ; " +
214+
"recirculation ${lu.loopMode}, sens de l'air ${lu.airFlow}, chauffages ${chauds ?: "absents"} ; " +
180215
"échelle ×${String.format(Locale.US, "%.2f", echelle)} (écran ${mesures.widthPixels}×" +
181216
"${mesures.heightPixels} px, ${mesures.densityDpi} dpi)")
182217
afficher()
@@ -297,6 +332,35 @@ object HvacPopupOverlay {
297332
ecrire { MG4Hardware.setClimateLoopMode(mode) }
298333
}
299334

335+
/**
336+
* Siège chauffant : un appui = niveau suivant (éteint → 1 → 2 → 3 → éteint), comme le
337+
* raccourci. La commande du véhicule avance cran par cran jusqu'au niveau voulu, ce qui prend
338+
* du temps : plusieurs appuis rapprochés ne donnent qu'UNE écriture, celle du niveau final.
339+
*/
340+
private fun cyclerSiege(gauche: Boolean) {
341+
val c = chauffages ?: return
342+
relancerDelai()
343+
val actuel = if (gauche) c.siegeGauche else c.siegeDroit
344+
val suivant = HvacPopup.niveauSiegeSuivant(actuel) ?: return
345+
val cote = if (gauche) "gauche" else "droit"
346+
AppLogger.i(TAG, "Pop-up HVAC — siège chauffant $cote : $actuel → $suivant")
347+
chauffages = if (gauche) c.copy(siegeGauche = suivant) else c.copy(siegeDroit = suivant)
348+
afficher()
349+
ecrireDiffere("siège $cote") {
350+
if (gauche) MG4Hardware.setSeatHeatLeft(suivant) else MG4Hardware.setSeatHeatRight(suivant)
351+
}
352+
}
353+
354+
private fun basculerVolant() {
355+
val c = chauffages ?: return
356+
relancerDelai()
357+
val actuel = c.volant ?: return
358+
AppLogger.i(TAG, "Pop-up HVAC — volant chauffant : $actuel → ${!actuel}")
359+
chauffages = c.copy(volant = !actuel)
360+
afficher()
361+
ecrire { MG4Hardware.setSteeringHeat(!actuel) }
362+
}
363+
300364
private fun ecrireDiffere(reglage: String, action: () -> Boolean) {
301365
differees.remove(reglage)?.let { handler.removeCallbacks(it) }
302366
val r = Runnable {
@@ -331,9 +395,11 @@ object HvacPopupOverlay {
331395
if (SystemClock.uptimeMillis() < silenceJusqua) return
332396
vehicule.execute {
333397
val lu = lireEtat() ?: return@execute
398+
val chauds = lireChauffages()
334399
handler.post {
335400
if (overlayView != null && ecrituresEnVol == 0 && differees.isEmpty()) {
336401
etat = lu
402+
chauffages = chauds
337403
afficher()
338404
}
339405
}
@@ -365,6 +431,13 @@ object HvacPopupOverlay {
365431
v.bascule(v.airFeet, s.airFlow?.let { air?.feet == true })
366432
v.bascule(v.airWindshield, s.airFlow?.let { air?.windshield == true })
367433
v.bascule(v.airRear, s.defrostRear)
434+
chauffages?.let { c ->
435+
v.siege(v.siegeGauche, R.string.climate_seat_left, c.siegeGauche)
436+
v.siege(v.siegeDroit, R.string.climate_seat_right, c.siegeDroit)
437+
v.bascule(v.volant, c.volant)
438+
v.volant.text = v.localized.getString(R.string.climate_steering_heat) + "\n" +
439+
v.localized.getString(if (c.volant == true) R.string.climate_on else R.string.climate_off)
440+
}
368441
}
369442

370443
private fun fermer() {
@@ -377,6 +450,7 @@ object HvacPopupOverlay {
377450
restantes.forEach { handler.removeCallbacks(it); it.run() }
378451
vues = null
379452
etat = null
453+
chauffages = null
380454
val v = overlayView ?: return
381455
overlayView = null
382456
try {
@@ -404,6 +478,10 @@ object HvacPopupOverlay {
404478
val airWindshield: MaterialButton = view.findViewById(R.id.hvac_btn_air_windshield_front)
405479
val airRear: MaterialButton = view.findViewById(R.id.hvac_btn_air_windshield_rear)
406480
val ac: MaterialButton = view.findViewById(R.id.hvac_btn_ac)
481+
val colonneChauffages: View = view.findViewById(R.id.hvac_heat_column)
482+
val siegeGauche: MaterialButton = view.findViewById(R.id.hvac_btn_seat_left)
483+
val siegeDroit: MaterialButton = view.findViewById(R.id.hvac_btn_seat_right)
484+
val volant: MaterialButton = view.findViewById(R.id.hvac_btn_steering_heat)
407485
val fermer: MaterialButton = view.findViewById(R.id.hvac_btn_close)
408486

409487
val recirculation: Map<Int, MaterialButton> = mapOf(
@@ -442,6 +520,12 @@ object HvacPopupOverlay {
442520
bouton.alpha = if (actif != null) 1f else 0.35f
443521
}
444522

523+
/** Siège chauffant : son nom, et son niveau en pastilles ; allumé dès le niveau 1. */
524+
fun siege(bouton: MaterialButton, nom: Int, niveau: Int?) {
525+
bascule(bouton, niveau?.let { it > 0 })
526+
bouton.text = localized.getString(nom) + "\n" + HvacPopup.pastilles(niveau ?: 0)
527+
}
528+
445529
/**
446530
* Repère à gauche de la croix : elle se pilote au joystick droit du volant. Sans service
447531
* d'accessibilité le joystick ne nous parvient pas (il règle le volume) : le repère
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Cadre de la zone du pop-up HVAC pilotée au joystick : repère, croix, température et
3+
ventilation. Trait épais et fond léger, choisis sur maquette (2026-10-09). -->
4+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
5+
android:shape="rectangle">
6+
<corners android:radius="12dp" />
7+
<solid android:color="@color/hvac_zone_fill" />
8+
<stroke android:width="3dp" android:color="@color/hvac_zone_stroke" />
9+
</shape>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Google Material Symbols « seat_heat_left » (Outlined, 24 px) — Apache License 2.0.
3+
Source : github.com/google/material-design-icons, symbols/android/seat_heat_left.
4+
Teinte d'origine retirée : c'est le bouton qui colore l'icône (iconTint). -->
5+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
6+
android:width="24dp"
7+
android:height="24dp"
8+
android:viewportWidth="960"
9+
android:viewportHeight="960">
10+
<path
11+
android:fillColor="@android:color/white"
12+
android:pathData="M801,502L735,458L748,437Q754,428 757,418.5Q760,409 760,398Q760,384 755,371Q750,358 740,348Q719,327 707.5,299.5Q696,272 696,242Q696,219 702.5,198Q709,177 721,158L734,138L801,182L787,203Q781,212 778.5,221.5Q776,231 776,242Q776,256 781,269Q786,282 796,292Q817,313 828.5,340.5Q840,368 840,398Q840,421 833.5,442Q827,463 815,482L801,502ZM633,502L567,458L580,437Q586,428 589,418.5Q592,409 592,398Q592,384 587,371Q582,358 572,348Q551,327 539.5,299.5Q528,272 528,242Q528,219 534.5,198Q541,177 553,158L566,138L633,182L619,203Q613,212 610.5,221.5Q608,231 608,242Q608,256 613,269Q618,282 628,292Q649,313 660.5,340.5Q672,368 672,398Q672,421 665.5,442Q659,463 647,482L633,502ZM466,502L399,458L413,437Q419,428 422,418.5Q425,409 425,398Q425,384 419.5,371Q414,358 404,348Q383,327 371.5,299.5Q360,272 360,242Q360,219 366,198Q372,177 385,158L399,138L466,182L452,203Q446,211 443,221Q440,231 440,242Q440,256 445,269Q450,282 460,292Q481,313 492.5,340.5Q504,368 504,398Q504,421 497.5,442Q491,463 479,482L466,502ZM302,840Q275,840 254,823.5Q233,807 225,781L129,408Q125,393 122.5,377.5Q120,362 120,346Q120,318 126.5,291Q133,264 146,239Q155,221 171.5,210.5Q188,200 208,200Q231,200 248,217Q265,234 265,257Q265,268 261,278Q257,288 249,296Q230,316 226.5,342.5Q223,369 234,394L254,436Q283,499 301.5,566Q320,633 320,703L320,747Q337,736 356,728Q375,720 396,720L600,720Q634,720 657.5,743.5Q681,767 681,800L681,840L302,840Z"/>
13+
</vector>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Google Material Symbols « seat_heat_right » (Outlined, 24 px) — Apache License 2.0.
3+
Source : github.com/google/material-design-icons, symbols/android/seat_heat_right.
4+
Teinte d'origine retirée : c'est le bouton qui colore l'icône (iconTint). -->
5+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
6+
android:width="24dp"
7+
android:height="24dp"
8+
android:viewportWidth="960"
9+
android:viewportHeight="960">
10+
<path
11+
android:fillColor="@android:color/white"
12+
android:pathData="M561,502L495,458L508,437Q514,428 517,418.5Q520,409 520,398Q520,384 514.5,371Q509,358 499,348Q478,327 467,299.5Q456,272 456,242Q456,219 462.5,198Q469,177 481,158L494,138L561,182L547,203Q541,212 538,221.5Q535,231 535,242Q535,256 540.5,269Q546,282 556,292Q577,313 588,340.5Q599,368 599,398Q599,421 593,442Q587,463 575,482L561,502ZM393,502L327,458L340,437Q346,428 349,418.5Q352,409 352,398Q352,384 346.5,371Q341,358 331,348Q310,327 299,299.5Q288,272 288,242Q288,219 294.5,198Q301,177 313,158L326,138L393,182L379,203Q373,212 370,221.5Q367,231 367,242Q367,256 372.5,269Q378,282 388,292Q409,313 420,340.5Q431,368 431,398Q431,421 425,442Q419,463 407,482L393,502ZM225,502L159,458L173,437Q179,428 181.5,418.5Q184,409 184,398Q184,384 179,371Q174,358 164,348Q143,327 131.5,299.5Q120,272 120,242Q120,219 126,198Q132,177 145,158L159,138L225,182L211,203Q205,211 202,221Q199,231 199,242Q199,256 204.5,269Q210,282 220,292Q241,313 252.5,340.5Q264,368 264,398Q264,421 257.5,442Q251,463 239,482L225,502ZM279,840L279,800Q279,767 302,743.5Q325,720 359,720L563,720Q584,720 603,728Q622,736 639,747L639,703Q639,633 658,566Q677,499 706,436L725,394Q736,369 733,342.5Q730,316 711,296Q703,288 699,278Q695,268 695,257Q695,234 711.5,217Q728,200 751,200Q771,200 788,210.5Q805,221 814,239Q827,264 833.5,291Q840,318 840,346Q840,362 838,377.5Q836,393 831,408L735,781Q727,807 705.5,823.5Q684,840 657,840L279,840Z"/>
13+
</vector>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Google Material Symbols « steering_wheel_heat » (Outlined, 24 px) — Apache License 2.0.
3+
Source : github.com/google/material-design-icons, symbols/android/steering_wheel_heat.
4+
Teinte d'origine retirée : c'est le bouton qui colore l'icône (iconTint). -->
5+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
6+
android:width="24dp"
7+
android:height="24dp"
8+
android:viewportWidth="960"
9+
android:viewportHeight="960">
10+
<path
11+
android:fillColor="@android:color/white"
12+
android:pathData="M881,382L815,338L828,317Q834,308 837,298.5Q840,289 840,278Q840,264 835,251Q830,238 820,228Q799,207 787.5,179.5Q776,152 776,122Q776,99 782.5,78Q789,57 801,38L814,18L881,62L867,83Q861,92 858.5,101.5Q856,111 856,122Q856,136 861,149Q866,162 876,172Q897,193 908.5,220.5Q920,248 920,278Q920,301 913.5,322Q907,343 895,362L881,382ZM713,382L647,338L660,317Q666,308 669,298.5Q672,289 672,278Q672,264 667,251Q662,238 652,228Q631,207 619.5,179.5Q608,152 608,122Q608,99 614.5,78Q621,57 633,38L646,18L713,62L699,83Q693,92 690.5,101.5Q688,111 688,122Q688,136 693,149Q698,162 708,172Q729,193 740.5,220.5Q752,248 752,278Q752,301 745.5,322Q739,343 727,362L713,382ZM520,797Q629,783 706,706Q783,629 797,520L640,520L520,670L520,797ZM546,382L479,338L493,317Q499,308 502,298.5Q505,289 505,278Q505,264 499.5,251Q494,238 484,228Q463,207 451.5,179.5Q440,152 440,122Q440,99 446,78Q452,57 465,38L479,18L546,62L532,83Q526,91 523,101Q520,111 520,122Q520,136 525,149Q530,162 540,172Q561,193 572.5,220.5Q584,248 584,278Q584,301 577.5,322Q571,343 559,362L546,382ZM163,520Q177,629 254,706Q331,783 440,797L440,670L320,520L163,520ZM163,440L878,440Q879,450 879.5,460Q880,470 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,345 159,240.5Q238,136 360,98L360,184Q281,216 227.5,283.5Q174,351 163,440Z"/>
13+
</vector>

0 commit comments

Comments
 (0)