Skip to content

Commit 032d518

Browse files
committed
Add HVAC control to profiles
1 parent f99b9a5 commit 032d518

13 files changed

Lines changed: 577 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,31 @@ un réglage que l'utilisateur croit global, et il l'est.
254254
- Le nom du profil et le réglage « profil par défaut » restent visibles sur les trois onglets
255255
- Volant et sièges chauffants disposent d'un interrupteur de **prise en compte** : décoché, le
256256
profil ne touche pas au réglage au lieu de l'éteindre
257+
- **Climatisation** : bloc facultatif de l'onglet Confort, décoché par défaut, reprenant les
258+
commandes du Dashboard — marche/arrêt, A/C, ventilation automatique, consigne, vitesse,
259+
recyclage et dégivrages
260+
261+
#### Bloc climatisation du profil
262+
Décoché, le profil ne touche pas à la climatisation : c'est le comportement de toujours, et celui
263+
de tous les profils déjà enregistrés. Coché, il applique ce qu'il porte au démarrage, au contact
264+
et à chaque application manuelle.
265+
266+
Trois règles s'y appliquent, qui viennent du véhicule et non de l'interface :
267+
268+
| Règle | Conséquence |
269+
|---|---|
270+
| Clim éteinte | Rien d'autre n'est écrit — une consigne posée sur une clim à l'arrêt la rallume sur certains firmwares |
271+
| Mode AUTO | La vitesse de ventilation n'est pas appliquée : la régler ferait sortir du mode auto |
272+
| « Inchangé » | Recyclage et dégivrages ne sont pas touchés — sans cette valeur, un profil qui ne s'en préoccupe pas les éteindrait |
273+
274+
Les curseurs sont bornés à la saisie (15–33 °C, 1–11), mais les **vraies** limites sont lues sur le
275+
véhicule au moment d'appliquer et la consigne y est clampée : elles diffèrent entre A9 et les
276+
autres firmwares.
277+
278+
> [!NOTE]
279+
> **Le profil est prioritaire sur l'automatisation A/C.** Si le profil actif porte un bloc
280+
> climatisation, l'automatisation par température ne fait rien et le journalise. Sans cette règle
281+
> les deux s'écriraient dessus au contact, dans un ordre que rien ne garantit.
257282
258283
### Compatibilité firmware inconnue (UNKNOWN)
259284
- Dialog d'avertissement au démarrage si le firmware n'est pas reconnu
@@ -1069,6 +1094,30 @@ users read as global, and it is.
10691094
- The profile name and the "default profile" setting stay visible across the three tabs
10701095
- Heated steering wheel and seats have an **apply** switch: unchecked, the profile leaves the
10711096
setting alone instead of turning it off
1097+
- **Air conditioning**: an optional block in the Comfort tab, unchecked by default, carrying the
1098+
Dashboard's own controls — on/off, A/C, auto fan, setpoint, fan speed, recirculation, defrosters
1099+
1100+
#### The profile's climate block
1101+
Unchecked, the profile does not touch the climate: that is the long-standing behaviour, and the
1102+
one every stored profile keeps. Checked, it applies what it carries at startup, at ignition, and
1103+
on every manual application.
1104+
1105+
Three rules apply, and they come from the vehicle rather than the interface:
1106+
1107+
| Rule | Consequence |
1108+
|---|---|
1109+
| Climate off | Nothing else is written — a setpoint sent to a stopped climate turns it back on with some firmwares |
1110+
| AUTO mode | The fan speed is not applied: setting it would leave auto mode |
1111+
| "Unchanged" | Recirculation and defrosters are left alone — without that value, a profile that says nothing about them would turn them off |
1112+
1113+
The sliders are bounded for input (15–33 °C, 1–11), but the **real** limits are read from the
1114+
vehicle when applying and the setpoint is clamped to them: they differ between A9 and the other
1115+
firmwares.
1116+
1117+
> [!NOTE]
1118+
> **The profile wins over the A/C automation.** If the active profile carries a climate block, the
1119+
> temperature automation does nothing and logs it. Without that rule the two would write over each
1120+
> other at ignition, in an order nothing guarantees.
10721121
10731122
### Unknown firmware (UNKNOWN)
10741123
- Warning dialog at startup when the firmware is not recognised

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5495,6 +5495,74 @@ object MG4Hardware {
54955495
*
54965496
* ⚠️ Bloquant (plusieurs secondes avec les bascules) → appeler depuis un thread IO.
54975497
*/
5498+
/**
5499+
* Applique le bloc clim d'un profil — exactement ce qu'il porte, et rien d'autre.
5500+
*
5501+
* Distinct d'[applyClimatePreset], qui force marche et A/C à ON : un préréglage
5502+
* d'automatisation sert toujours à FAIRE fonctionner la clim, alors qu'un profil doit aussi
5503+
* pouvoir l'éteindre. D'où deux entrées plutôt qu'un paramètre de plus, les deux appelants
5504+
* n'ayant pas la même intention.
5505+
*
5506+
* Les `null` valent « ne pas y toucher », comme dans l'automatisation : un profil qui ne se
5507+
* prononce pas sur le dégivrage ne doit pas l'éteindre au passage.
5508+
*/
5509+
fun applyProfileClimate(
5510+
power: Boolean,
5511+
ac: Boolean,
5512+
autoMode: Boolean,
5513+
targetTemp: Int,
5514+
fanLevel: Int,
5515+
defrostFront: Boolean?,
5516+
defrostRear: Boolean?,
5517+
loopMode: Int?
5518+
): Boolean {
5519+
val state = getClimateState() ?: run {
5520+
AppLogger.w(CLIM_TAG, "Profil : état clim illisible → abandon")
5521+
return false
5522+
}
5523+
// Clim éteinte : tout le reste n'aurait aucun sens, et écrire une consigne sur une clim
5524+
// à l'arrêt la rallume sur certains firmwares.
5525+
if (!power) {
5526+
val ok = setClimatePower(false)
5527+
AppLogger.i(CLIM_TAG, "Profil : climatisation ÉTEINTE → ok=$ok")
5528+
return ok
5529+
}
5530+
var ok = setClimatePower(true)
5531+
ok = setClimateAc(ac) && ok
5532+
ok = setClimateTemp(targetTemp.coerceIn(state.tempMin, state.tempMax)) && ok
5533+
5534+
// ⚠️ Mode auto et ventilation manuelle s'excluent : régler une vitesse fait sortir du
5535+
// mode auto, et activer le mode auto reprend la main sur la vitesse. Appliquer les deux
5536+
// donnerait un état final décidé par le seul ordre des appels, pas par l'utilisateur.
5537+
if (autoMode) {
5538+
ok = setClimateAuto(true) && ok
5539+
} else {
5540+
ok = setClimateFan(fanLevel.coerceIn(state.fanMin, state.fanMax)) && ok
5541+
}
5542+
5543+
// Deux conditions : que le profil se prononce, ET que le véhicule expose le réglage.
5544+
if (defrostFront != null && state.defrostFront != null) {
5545+
ok = setClimateDefrostFront(defrostFront) && ok
5546+
}
5547+
if (defrostRear != null && state.defrostRear != null) {
5548+
ok = setClimateDefrostRear(defrostRear) && ok
5549+
}
5550+
if (loopMode != null) {
5551+
val mode = when (loopMode) {
5552+
0 -> LoopMode.INNER
5553+
1 -> LoopMode.OUTSIDE
5554+
else -> LoopMode.AUTO
5555+
}
5556+
ok = setClimateLoopMode(mode) && ok
5557+
}
5558+
5559+
AppLogger.i(CLIM_TAG, "Profil : clim=ON A/C=$ac consigne=$targetTemp " +
5560+
(if (autoMode) "ventilation=AUTO" else "vent=$fanLevel") +
5561+
" dégAV=${defrostFront ?: "inchangé"} dégAR=${defrostRear ?: "inchangé"} " +
5562+
"recyclage=${loopMode ?: "inchangé"} → ok=$ok")
5563+
return ok
5564+
}
5565+
54985566
fun applyClimatePreset(
54995567
targetTemp: Int,
55005568
fanLevel: Int,

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ data class DrivingProfile(
5454
val escEnabled: Boolean? = null,
5555
val drowsinessEnabled: Boolean? = null,
5656
val drowsinessSensitivity: Int? = null, // 1=Faible, 2=Standard, 3=Élevé
57+
// ── Climatisation ────────────────────────────────────────────────────────────
58+
/**
59+
* Interrupteur du bloc clim. FAUX par défaut, et c'est ce que Gson donne aux profils
60+
* enregistrés avant la fonctionnalité (champ absent → défaut de la JVM) : aucun d'eux ne se
61+
* met donc à piloter la climatisation.
62+
*
63+
* ⚠️ C'est aussi ce qui rend les défauts ci-dessous sans conséquence. Un profil ancien les
64+
* lit à 0/false, ce qui n'aurait aucun sens appliqué tel quel — mais ils ne sont JAMAIS lus
65+
* tant que celui-ci est faux.
66+
*/
67+
val hvacEnabled: Boolean = false,
68+
val hvacPower: Boolean = true,
69+
val hvacAc: Boolean = true,
70+
/** Ventilation automatique. Exclusive de [hvacFan] : voir MG4Hardware.applyProfileClimate. */
71+
val hvacAuto: Boolean = false,
72+
val hvacTemp: Int = 21,
73+
val hvacFan: Int = 4,
74+
/** `null` = ne pas y toucher. Même convention que l'automatisation A/C. */
75+
val hvacDefrostFront: Boolean? = null,
76+
val hvacDefrostRear: Boolean? = null,
77+
/** `null` = inchangé, sinon 0=Intérieur, 1=Extérieur, 2=Auto. */
78+
val hvacLoopMode: Int? = null,
5779
// [BT-PROFILES] MAC de l'appareil Bluetooth associé à ce profil (null = aucun)
5880
val btDeviceMac: String? = null
5981
) {

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ object ProfileApplier {
117117
}
118118
}
119119

120+
// Climatisation — facultative, décochée par défaut. Enchaîne des bascules qui
121+
// prennent plusieurs secondes, mais on est déjà sur le dispatcher IO sous verrou :
122+
// rien d'autre n'écrit sur le véhicule pendant ce temps.
123+
if (profile.hvacEnabled && MG4Hardware.hasClimateControl()) {
124+
val climOk = MG4Hardware.applyProfileClimate(
125+
power = profile.hvacPower,
126+
ac = profile.hvacAc,
127+
autoMode = profile.hvacAuto,
128+
targetTemp = profile.hvacTemp,
129+
fanLevel = profile.hvacFan,
130+
defrostFront = profile.hvacDefrostFront,
131+
defrostRear = profile.hvacDefrostRear,
132+
loopMode = profile.hvacLoopMode
133+
)
134+
AppLogger.i(TAG, " Climatisation du profil appliquée → $climOk")
135+
}
136+
120137
AppLogger.i(TAG, "Profil '${profile.name}' Katman1 terminé — ok=$ok")
121138
onComplete?.invoke(ok)
122139

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.mg4.control.hardware.MG4Hardware
3333
import com.mg4.control.hardware.MG4Hardware.AebMode
3434
import com.mg4.control.hardware.MG4Hardware.Swi68Mode
3535
import com.mg4.control.model.RegenLevel
36+
import com.mg4.control.profile.ActiveProfile
3637
import com.mg4.control.profile.ProfileApplier
3738
import com.mg4.control.profile.ProfileManager
3839
import com.mg4.control.shortcut.RegenCycle
@@ -1265,6 +1266,19 @@ class MG4ControlService : Service() {
12651266
AppLogger.i(TAG, "Auto A/C ($origin) : clim non pilotable sur ce firmware")
12661267
return
12671268
}
1269+
// LE PROFIL EST PRIORITAIRE. S'il porte son propre bloc clim, l'automatisation n'a rien
1270+
// à dire : sans cette règle les deux s'écriraient dessus au contact, dans un ordre que
1271+
// rien ne garantit, et le résultat dépendrait de qui finit en dernier.
1272+
//
1273+
// Le profil actif est renseigné DÈS L'ENTRÉE de ProfileApplier.apply(), avant même les
1274+
// écritures véhicule : quand cette vérification a lieu, il désigne bien le profil du
1275+
// cycle en cours.
1276+
val profilActif = ActiveProfile.id(ctx)?.let { ProfileManager(ctx).getById(it) }
1277+
if (profilActif?.hvacEnabled == true) {
1278+
AppLogger.i(TAG, "Auto A/C ($origin) : le profil actif '${profilActif.name}' porte " +
1279+
"sa propre climatisation — priorité au profil")
1280+
return
1281+
}
12681282
val since = System.currentTimeMillis() - climateAutoLastRunMs
12691283
if (climateAutoLastRunMs != 0L && since < CLIMATE_AUTO_DEBOUNCE_MS) {
12701284
AppLogger.i(TAG, "Auto A/C ($origin) : déjà appliquée il y a ${since / 1000}s — skip")

‎app/src/main/java/com/mg4/control/ui/ProfileEditFragment.kt‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.widget.*
99
import androidx.fragment.app.Fragment
1010
import androidx.navigation.fragment.findNavController
1111
import com.google.android.material.button.MaterialButton
12+
import com.google.android.material.slider.Slider
1213
import com.mg4.control.R
1314
import com.mg4.control.bluetooth.BluetoothProfileManager
1415
import com.mg4.control.hardware.MG4Hardware
@@ -37,6 +38,14 @@ import kotlinx.coroutines.withContext
3738
class ProfileEditFragment : Fragment() {
3839

3940
companion object {
41+
/** Bornes des curseurs de climatisation — les VRAIES limites sont lues sur le véhicule
42+
* au moment d'appliquer, et la consigne y est clampée. Celles-ci ne servent qu'à la
43+
* saisie, et doivent couvrir tous les firmwares (A9 accepte 17–33 °C et 1–11). */
44+
const val HVAC_TEMP_MIN = 15
45+
const val HVAC_TEMP_MAX = 33
46+
const val HVAC_FAN_MIN = 1
47+
const val HVAC_FAN_MAX = 11
48+
4049
/**
4150
* Passage de données depuis [ProfileFragment]. Un profil complet ne tient pas
4251
* confortablement dans un Bundle (enums + une vingtaine de champs) et l'écran n'est ouvert
@@ -105,6 +114,19 @@ class ProfileEditFragment : Fragment() {
105114
var seatHeatEnabled = data.appliesSeatHeat
106115
var seatLeft = data.seatHeatLeft
107116
var seatRight = data.seatHeatRight
117+
var hvacEnabledSel = data.hvacEnabled
118+
var hvacPowerSel = data.hvacPower
119+
var hvacAcSel = data.hvacAc
120+
var hvacAutoSel = data.hvacAuto
121+
// ⚠️ CLAMPÉS À LA LECTURE, et ce n'est pas de la prudence gratuite : Gson n'appelle pas
122+
// le constructeur Kotlin, donc un profil enregistré avant cette fonctionnalité rend 0
123+
// pour ces deux champs. Le curseur Material LÈVE une exception si on lui pose une valeur
124+
// hors de [valueFrom, valueTo] — l'éditeur planterait à l'ouverture d'un ancien profil.
125+
var hvacTempSel = data.hvacTemp.coerceIn(HVAC_TEMP_MIN, HVAC_TEMP_MAX)
126+
var hvacFanSel = data.hvacFan.coerceIn(HVAC_FAN_MIN, HVAC_FAN_MAX)
127+
var hvacDfSel = data.hvacDefrostFront
128+
var hvacDrSel = data.hvacDefrostRear
129+
var hvacLoopSel = data.hvacLoopMode
108130
var adasMode = data.adasMode
109131
var swi68Mode = data.swi68AdasMode
110132
var swi132SasMode = data.swi132SasMode // 0=Off, 2=Manuel, 3=Intelligent
@@ -220,6 +242,97 @@ class ProfileEditFragment : Fragment() {
220242
setBtnsEnabled(seatLeftBtns + seatRightBtns, checked)
221243
}
222244

245+
// ── Climatisation — bloc facultatif du profil ────────────────────────
246+
val swHvac = view.findViewById<Switch>(R.id.sw_hvac_enabled)
247+
val btnPower = view.findViewById<MaterialButton>(R.id.btn_hvac_power)
248+
val btnAc = view.findViewById<MaterialButton>(R.id.btn_hvac_ac)
249+
val btnAuto = view.findViewById<MaterialButton>(R.id.btn_hvac_auto)
250+
val sldTemp = view.findViewById<Slider>(R.id.sld_hvac_temp)
251+
val tvTemp = view.findViewById<TextView>(R.id.tv_hvac_temp)
252+
val sldFan = view.findViewById<Slider>(R.id.sld_hvac_fan)
253+
val tvFan = view.findViewById<TextView>(R.id.tv_hvac_fan)
254+
val loopBtns = listOf(
255+
view.findViewById<MaterialButton>(R.id.btn_hvac_loop_in),
256+
view.findViewById<MaterialButton>(R.id.btn_hvac_loop_out),
257+
view.findViewById<MaterialButton>(R.id.btn_hvac_loop_auto),
258+
view.findViewById<MaterialButton>(R.id.btn_hvac_loop_none)
259+
)
260+
val dfBtns = listOf(
261+
view.findViewById<MaterialButton>(R.id.btn_hvac_df_off),
262+
view.findViewById<MaterialButton>(R.id.btn_hvac_df_on),
263+
view.findViewById<MaterialButton>(R.id.btn_hvac_df_none)
264+
)
265+
val drBtns = listOf(
266+
view.findViewById<MaterialButton>(R.id.btn_hvac_dr_off),
267+
view.findViewById<MaterialButton>(R.id.btn_hvac_dr_on),
268+
view.findViewById<MaterialButton>(R.id.btn_hvac_dr_none)
269+
)
270+
271+
/**
272+
* Trois grisages en cascade, qui reproduisent des règles du véhicule et non des choix
273+
* d'interface :
274+
* • interrupteur sur OFF → le profil ne touche pas à la clim, rien n'est modifiable ;
275+
* • clim éteinte → consigne, ventilation et le reste n'ont plus de sens ;
276+
* • mode AUTO → la ventilation manuelle est exclue, régler une vitesse ferait sortir
277+
* du mode auto (voir MG4Hardware.applyProfileClimate).
278+
*/
279+
fun majHvac() {
280+
val actif = hvacEnabledSel
281+
val allume = actif && hvacPowerSel
282+
setBtnsEnabled(listOf(btnPower), actif)
283+
setBtnsEnabled(listOf(btnAc, btnAuto) + loopBtns + dfBtns + drBtns, allume)
284+
listOf(sldTemp, sldFan).forEach { it.isEnabled = allume }
285+
sldFan.isEnabled = allume && !hvacAutoSel
286+
sldTemp.alpha = if (allume) 1f else 0.35f
287+
sldFan.alpha = if (allume && !hvacAutoSel) 1f else 0.35f
288+
tvTemp.alpha = sldTemp.alpha
289+
tvFan.alpha = sldFan.alpha
290+
activateBtn(btnPower, hvacPowerSel)
291+
activateBtn(btnAc, hvacAcSel)
292+
activateBtn(btnAuto, hvacAutoSel)
293+
}
294+
295+
bindGroup(listOf(
296+
loopBtns[0] to 0, loopBtns[1] to 1, loopBtns[2] to 2, loopBtns[3] to null
297+
), hvacLoopSel) { hvacLoopSel = it }
298+
bindGroup(listOf(
299+
dfBtns[0] to false, dfBtns[1] to true, dfBtns[2] to null
300+
), hvacDfSel) { hvacDfSel = it }
301+
bindGroup(listOf(
302+
drBtns[0] to false, drBtns[1] to true, drBtns[2] to null
303+
), hvacDrSel) { hvacDrSel = it }
304+
305+
// Bascules et non groupes : ce sont les mêmes commandes que sur le Dashboard, où un
306+
// bouton unique s'allume quand le réglage est actif.
307+
btnPower.setOnClickListener { hvacPowerSel = !hvacPowerSel; majHvac() }
308+
btnAc.setOnClickListener { hvacAcSel = !hvacAcSel; majHvac() }
309+
btnAuto.setOnClickListener { hvacAutoSel = !hvacAutoSel; majHvac() }
310+
311+
sldTemp.value = hvacTempSel.toFloat()
312+
tvTemp.text = getString(R.string.profile_hvac_temp_value, hvacTempSel)
313+
sldTemp.addOnChangeListener { _, v, _ ->
314+
hvacTempSel = v.toInt()
315+
tvTemp.text = getString(R.string.profile_hvac_temp_value, hvacTempSel)
316+
}
317+
sldFan.value = hvacFanSel.toFloat()
318+
tvFan.text = hvacFanSel.toString()
319+
sldFan.addOnChangeListener { _, v, _ ->
320+
hvacFanSel = v.toInt()
321+
tvFan.text = hvacFanSel.toString()
322+
}
323+
324+
swHvac.isChecked = hvacEnabledSel
325+
swHvac.setOnCheckedChangeListener { _, checked ->
326+
hvacEnabledSel = checked
327+
majHvac()
328+
}
329+
majHvac()
330+
331+
// Firmware sans clim pilotable : la section entière disparaît, comme la page Clim du
332+
// Dashboard. Proposer des réglages qui n'écriraient rien serait pire que rien.
333+
view.findViewById<View>(R.id.section_hvac)?.visibility =
334+
if (MG4Hardware.hasClimateControl()) View.VISIBLE else View.GONE
335+
223336
// ── Sections Climat — masquées si pas de chauffage (SWI69/SWI131) ────
224337
val hasHeat = FirmwareInfo.hasHeatFeatures()
225338
val heatVis = if (hasHeat) View.VISIBLE else View.GONE
@@ -537,6 +650,15 @@ class ProfileEditFragment : Fragment() {
537650
lasVibrationReminder = lasVibrationReminderSel,
538651
energySaving = energySavingSel,
539652
tsrEnabled = tsrEnabledSel,
653+
hvacEnabled = hvacEnabledSel,
654+
hvacPower = hvacPowerSel,
655+
hvacAc = hvacAcSel,
656+
hvacAuto = hvacAutoSel,
657+
hvacTemp = hvacTempSel,
658+
hvacFan = hvacFanSel,
659+
hvacDefrostFront = hvacDfSel,
660+
hvacDefrostRear = hvacDrSel,
661+
hvacLoopMode = hvacLoopSel,
540662
btDeviceMac = selectedBtMac // [BT-PROFILES]
541663
)
542664
manager.save(profile)

0 commit comments

Comments
 (0)