Skip to content

Commit d8bc3e2

Browse files
committed
hidden feature test
1 parent 41b4f86 commit d8bc3e2

9 files changed

Lines changed: 873 additions & 0 deletions

File tree

‎app/src/main/java/com/mg4/control/MainActivity.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ class MainActivity : AppCompatActivity() {
162162
diagnosticUnlocked = true
163163
// Révèle immédiatement le bouton si l'onglet Réglages est déjà affiché
164164
findViewById<View>(R.id.btn_diagnostic)?.visibility = View.VISIBLE
165+
findViewById<View>(R.id.btn_lock_probe)?.visibility = View.VISIBLE
165166
Toast.makeText(this, getString(R.string.diagnostic_unlocked), Toast.LENGTH_SHORT).show()
166167
}
167168
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package com.mg4.control.hardware
2+
3+
import com.mg4.control.debug.AppLogger
4+
import com.mg4.control.model.LeaveLock
5+
import com.mg4.control.model.LeaveLock.Reading
6+
import com.mg4.control.util.FirmwareInfo
7+
import java.text.SimpleDateFormat
8+
import java.util.Date
9+
import java.util.Locale
10+
11+
/**
12+
* Sonde du réglage d'origine « Lock when leaving » (verrouiller en s'éloignant avec la clé).
13+
* PROVISOIRE — à retirer une fois l'essai conclu.
14+
*
15+
* Les écrans de réglages SWI133/68/165 portent cet interrupteur et le masquent quand la voiture
16+
* répond 3 (« indisponible ») ; c'est le cas sur la voiture d'essai (SWI133, 2026-10-11). Le
17+
* service d'origine ne filtrant rien, ce 3 vient de la voiture. Reste à savoir si elle accepte
18+
* quand même la consigne : c'est la SEULE écriture de cette sonde ([set]), celle de
19+
* l'interrupteur d'origine, par sa voie d'origine. Tout le reste est lu, rien d'autre n'est écrit.
20+
*
21+
* Trois usages :
22+
* 1. **L'écran d'essai** (bouton caché sous « Diagnostic ») : [poll] relit chaque seconde le
23+
* réglage et de quoi suivre un essai clé en main — verrouillage central, présence de la clé,
24+
* portes — et note chaque changement au [journal]. [set] puis [conclude] pour la consigne.
25+
* 2. **Le Diagnostic** : [run] prend un relevé, pour les firmwares que nous n'avons pas.
26+
* 3. **Le rapport** : [reports] réunit les relevés et le journal de l'essai.
27+
*/
28+
object LockProbe {
29+
30+
const val TAG = "MG4_LOCK"
31+
32+
/** Nom du réglage dans [poll] : l'écran d'essai l'affiche en grand. */
33+
const val LEAVE = "Lock when leaving"
34+
35+
private const val AREA_GLOBAL = 0x1000000
36+
private const val MAX_RELEVES = 6
37+
private const val MAX_JOURNAL = 200
38+
39+
/** État du chargeur de téléphone sans fil : 8 quand la clé n'est plus détectée (SWI133). */
40+
private const val PROP_CHARGER = 0x2140f445
41+
private const val PROP_DOORS = 0x2640c623
42+
private const val PROP_READY = 0x2140157c
43+
44+
private class Brute(val name: String, val id: Int)
45+
46+
/**
47+
* Lues telles quelles, JAMAIS écrites. Les deux premières sont le réglage et son pendant, à
48+
* recouper avec la voie d'origine. Les suivantes sont déclarées par le firmware (SWI133, 68,
49+
* 69) mais aucune appli d'origine ne s'en sert : leur sens n'est écrit nulle part, seule
50+
* leur valeur — et le fait qu'elle bouge ou non pendant l'essai — peut renseigner.
51+
*/
52+
private val BRUTES = listOf(
53+
Brute("COMFORT_LOCK brut", 0x2140d710),
54+
Brute("COMFORT_UNLOCK brut", 0x2140d711),
55+
Brute("COMFORT_LOCK_OPT", 0x2140c633),
56+
Brute("COMFORT_UNLOCK_OPT", 0x2140c634),
57+
Brute("AUTOMATIC_LOCK_OPT", 0x2140c631),
58+
Brute("AUTO_LOCKING_OPT", 0x2140c62f),
59+
Brute("PASSIVE_SINGLE_OPT", 0x2140c62e),
60+
Brute("SINGLE_POINT_ENT_OPT", 0x2140c630),
61+
Brute("PE_START_OPT", 0x2140c632),
62+
Brute("POWER_MODE_AUTO_OFF", 0x2140c636),
63+
)
64+
65+
/** Un relevé : le réglage par sa voie d'origine (null = illisible), puis tout ce qui est suivi. */
66+
class Releve(val leave: Int?, val lectures: List<Reading>)
67+
68+
private val historique = ArrayDeque<String>()
69+
private val journal = ArrayDeque<String>()
70+
71+
/** Relevés pris à l'ouverture du Diagnostic, puis le journal de l'écran d'essai. */
72+
val reports: List<String>
73+
get() {
74+
val essai = journal()
75+
return synchronized(historique) { historique.toList() } +
76+
(if (essai.isEmpty()) emptyList()
77+
else listOf("── Sonde verrouillage : journal de l'essai ──\n" + essai.joinToString("\n")))
78+
}
79+
80+
// Suivi : sous [verrou], le Diagnostic et l'écran d'essai pouvant lire en même temps.
81+
private val verrou = Any()
82+
private var dernieres: List<Reading>? = null
83+
private var zonesDeclarees: Map<Int, IntArray>? = null
84+
85+
// ── Diagnostic ───────────────────────────────────────────────────────────
86+
87+
/** Prend un relevé, le garde pour le rapport et en renvoie le texte. Hors du fil principal. */
88+
fun run(origin: String): String {
89+
val horloge = SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(Date())
90+
val lignes = listOf("══ Sonde verrouillage [$origin] · ${FirmwareInfo.getGeneration()} · $horloge ══") +
91+
poll().lectures.map { " ${it.name} : ${it.value}" }
92+
lignes.forEach { AppLogger.i(TAG, it) }
93+
synchronized(historique) {
94+
historique.addLast(lignes.joinToString("\n"))
95+
while (historique.size > MAX_RELEVES) historique.removeFirst()
96+
}
97+
return lignes.joinToString("\n")
98+
}
99+
100+
// ── Écran d'essai ────────────────────────────────────────────────────────
101+
102+
/**
103+
* Relit tout ce qui est suivi et note au journal ce qui a changé depuis la lecture
104+
* précédente ; [noter] y ajoute le relevé complet. Une vingtaine de lectures binder → hors du
105+
* fil principal.
106+
*/
107+
fun poll(noter: Boolean = false): Releve = synchronized(verrou) {
108+
val cpm = MG4Hardware.carPropertyManager()
109+
// Les zones ne changent pas : la liste des propriétés (un bon millier) n'est lue qu'une fois.
110+
val zones = zonesDeclarees ?: declarees(cpm)?.also { zonesDeclarees = it }
111+
val leave = LeaveLock.raw(MG4Hardware.readLeaveAutoLockRaw())
112+
val lectures = mutableListOf(
113+
Reading(LEAVE, LeaveLock.describe(leave)),
114+
Reading("Unlock when approaching", LeaveLock.describe(LeaveLock.raw(MG4Hardware.readApproachUnlockRaw()))),
115+
Reading("Verrouillage central", LeaveLock.lockState(LeaveLock.raw(MG4Hardware.readCentralLockRaw()))),
116+
Reading("Clé (chargeur sans fil)", LeaveLock.keyState(entier(cpm, zones, PROP_CHARGER))),
117+
Reading("Portes (1 = ouverte)", brute(cpm, zones, PROP_DOORS)),
118+
Reading("READY", brute(cpm, zones, PROP_READY)),
119+
)
120+
BRUTES.forEach { lectures += Reading(it.name, brute(cpm, zones, it.id)) }
121+
dernieres?.let { avant -> LeaveLock.changes(avant, lectures).forEach { note("changement · $it") } }
122+
if (noter) note("lecture · " + lectures.joinToString(" · ") { "${it.name} = ${it.value}" })
123+
dernieres = lectures
124+
Releve(leave, lectures)
125+
}
126+
127+
/**
128+
* Envoie la consigne de l'interrupteur d'origine et la note au journal. Faux si elle n'est
129+
* pas partie : firmware sans voie d'origine, service non lié ou voiture en mouvement.
130+
*/
131+
fun set(on: Boolean): Boolean {
132+
val avant = LeaveLock.raw(MG4Hardware.readLeaveAutoLockRaw())
133+
val envoyee = MG4Hardware.setLeaveAutoLock(on)
134+
note("consigne · ${if (on) "ACTIVER (1)" else "DÉSACTIVER (0)"} · avant : ${LeaveLock.describe(avant)} · " +
135+
if (envoyee) "envoyée" else "NON envoyée")
136+
return envoyee
137+
}
138+
139+
/** Relit le réglage après une consigne et note au journal ce qu'il faut en conclure. */
140+
fun conclude(on: Boolean): LeaveLock.Verdict {
141+
val apres = LeaveLock.raw(MG4Hardware.readLeaveAutoLockRaw())
142+
return LeaveLock.verdict(on, apres).also { note("verdict · ${it.text} (lu : ${LeaveLock.describe(apres)})") }
143+
}
144+
145+
/** Journal de l'essai, du plus ancien au plus récent. */
146+
fun journal(): List<String> = synchronized(journal) { journal.toList() }
147+
148+
fun clearJournal() = synchronized(journal) { journal.clear() }
149+
150+
private fun note(texte: String) {
151+
val heure = SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(Date())
152+
AppLogger.i(TAG, "essai · $texte")
153+
synchronized(journal) {
154+
journal.addLast("$heure $texte")
155+
while (journal.size > MAX_JOURNAL) journal.removeFirst()
156+
}
157+
}
158+
159+
// ── Lectures brutes ──────────────────────────────────────────────────────
160+
161+
/** Zones déclarées par le véhicule pour les propriétés suivies, ou null si la liste est illisible. */
162+
private fun declarees(cpm: Any?): Map<Int, IntArray>? = runCatching {
163+
val suivies = (BRUTES.map { it.id } + listOf(PROP_CHARGER, PROP_DOORS, PROP_READY)).toSet()
164+
(cpm!!.javaClass.getMethod("getPropertyList").invoke(cpm) as List<*>)
165+
.filterNotNull()
166+
.mapNotNull { c ->
167+
val id = runCatching { c.javaClass.getMethod("getPropertyId").invoke(c) as? Int }.getOrNull()
168+
?.takeIf { it in suivies } ?: return@mapNotNull null
169+
val zones = runCatching { c.javaClass.getMethod("getAreaIds").invoke(c) as? IntArray }.getOrNull()
170+
id to (zones?.takeIf { it.isNotEmpty() } ?: intArrayOf(0))
171+
}
172+
.toMap()
173+
}.getOrNull()
174+
175+
/**
176+
* Valeur d'une propriété, zone par zone si elle en a plusieurs ; à défaut, ce qui l'empêche
177+
* d'être lue — « non déclarée » et « refusée » sont déjà des réponses.
178+
*/
179+
private fun brute(cpm: Any?, zones: Map<Int, IntArray>?, id: Int): String {
180+
cpm ?: return "cpm absent"
181+
val z = zones?.get(id)
182+
return when {
183+
zones != null && z == null -> "non déclarée · " + lire(cpm, id, AREA_GLOBAL)
184+
z == null -> lire(cpm, id, AREA_GLOBAL)
185+
z.size == 1 -> lire(cpm, id, z[0])
186+
else -> z.joinToString(" ") { zone -> porte(zone) + "=" + lire(cpm, id, zone) }
187+
}
188+
}
189+
190+
/** Valeur entière d'une propriété globale, ou null si elle ne se lit pas. */
191+
private fun entier(cpm: Any?, zones: Map<Int, IntArray>?, id: Int): Int? {
192+
cpm ?: return null
193+
val zone = zones?.get(id)?.firstOrNull() ?: 0
194+
return runCatching { valeur(cpm, id, zone) }.recoverCatching { valeur(cpm, id, AREA_GLOBAL) }
195+
.getOrNull()?.toIntOrNull()
196+
}
197+
198+
/**
199+
* Valeur lisible, ou le nom de l'exception. Zone 0 déclarée pour une propriété globale :
200+
* selon la voie, c'est à la zone globale du HAL qu'elle répond, d'où le second essai.
201+
*/
202+
private fun lire(cpm: Any, id: Int, zone: Int): String = try {
203+
valeur(cpm, id, zone)
204+
} catch (e: Exception) {
205+
(if (zone == 0) runCatching { valeur(cpm, id, AREA_GLOBAL) }.getOrNull() else null) ?: run {
206+
val cause = e.cause ?: e
207+
cause.javaClass.simpleName + (cause.message?.let { " ($it)" } ?: "")
208+
}
209+
}
210+
211+
private fun valeur(cpm: Any, id: Int, zone: Int): String =
212+
cpm.javaClass.getMethod("getIntProperty", Int::class.java, Int::class.java)
213+
.invoke(cpm, id, zone)?.toString() ?: "null"
214+
215+
/** Nom d'une zone de porte (VehicleAreaDoor), ou la zone en hexadécimal si elle n'en est pas une. */
216+
private fun porte(zone: Int): String = when (zone) {
217+
0x1 -> "AVG"
218+
0x4 -> "AVD"
219+
0x10 -> "ARG"
220+
0x40 -> "ARD"
221+
else -> "0x" + Integer.toHexString(zone)
222+
}
223+
}

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.mg4.control.model.BatteryHeating
2424
import com.mg4.control.model.CustomDriveScale
2525
import com.mg4.control.model.DriveMode
2626
import com.mg4.control.model.HvacPopup
27+
import com.mg4.control.model.LeaveLock
2728
import com.mg4.control.model.RegenLevel
2829
import com.mg4.control.model.SeatHeat
2930
import com.mg4.control.util.FirmwareInfo
@@ -2759,6 +2760,61 @@ object MG4Hardware {
27592760
AppLogger.i(BEAM_TAG, "── fin DIAG ──")
27602761
}
27612762

2763+
// -------------------------------------------------------------------------
2764+
// Essai « Lock when leaving » (PROVISOIRE) — voir [LockProbe] et [LeaveLock]
2765+
//
2766+
// Réglage d'origine COMFORT_LOCK 0x2140d710 (verrouiller en s'éloignant avec la clé) et son
2767+
// pendant COMFORT_UNLOCK 0x2140d711 (déverrouiller à l'approche). Voies d'origine :
2768+
// • SWI133 : VPM ID_COMFORT_LOCK 0x6030005 / ID_COMFORT_UNLOCK 0x6030006,
2769+
// setIntPropertyRecovery (interrupteur de l'écran Portes, o0/v$p) ;
2770+
// • SWI68/165 : VehicleSettingManager get/setLeaveAutoLockMode, getApproachUnlockMode ;
2771+
// • A9 : aucune — ni méthode ni texte dans les applis d'origine.
2772+
// Lecture 0 = coupé, 1 = actif, 3 = indisponible (ligne masquée) ; consigne 0/1, l'état voulu.
2773+
//
2774+
// Verrouillage central VEHICLE_LOCK 0x11403800, ici en LECTURE seule (> 0 = verrouillé) :
2775+
// SWI133 VPM 0x603000c · SWI68/165 VehicleControlManager.getDoorLock · A9 getZonedVehicleLockState.
2776+
// -------------------------------------------------------------------------
2777+
2778+
private const val VPM_COMFORT_LOCK = 0x6030005
2779+
private const val VPM_COMFORT_UNLOCK = 0x6030006
2780+
private const val VPM_VEHICLE_LOCK = 0x603000c
2781+
2782+
/** Vrai si ce firmware a une commande d'origine pour « Lock when leaving ». */
2783+
internal fun hasLeaveAutoLockPath(): Boolean = sVpm != null || (FirmwareInfo.isVsmBased() && !isA9Vsm())
2784+
2785+
/** « Lock when leaving » par la voie d'origine. -1 si illisible ou sans voie. */
2786+
internal fun readLeaveAutoLockRaw(): Int = when {
2787+
isA9Vsm() -> -1
2788+
FirmwareInfo.isVsmBased() -> (callVsm("getLeaveAutoLockMode") as? Int) ?: -1
2789+
else -> getIntPropertyVpm(VPM_COMFORT_LOCK)
2790+
}
2791+
2792+
/** « Unlock when approaching » par la voie d'origine. -1 si illisible ou sans voie. */
2793+
internal fun readApproachUnlockRaw(): Int = when {
2794+
isA9Vsm() -> -1
2795+
FirmwareInfo.isVsmBased() -> (callVsm("getApproachUnlockMode") as? Int) ?: -1
2796+
else -> getIntPropertyVpm(VPM_COMFORT_UNLOCK)
2797+
}
2798+
2799+
/** Consigne de l'interrupteur d'origine « Lock when leaving ». Faux si elle n'est pas partie. */
2800+
internal fun setLeaveAutoLock(on: Boolean): Boolean {
2801+
val v = if (on) LeaveLock.SET_ON else LeaveLock.SET_OFF
2802+
val ok = when {
2803+
isA9Vsm() -> false
2804+
FirmwareInfo.isVsmBased() -> callVsmVoid("setLeaveAutoLockMode", v)
2805+
else -> setIntPropertyVpmRecovery(VPM_COMFORT_LOCK, v)
2806+
}
2807+
AppLogger.i(LockProbe.TAG, "setLeaveAutoLock(${if (on) "ON" else "OFF"}) écrit $v → $ok")
2808+
return ok
2809+
}
2810+
2811+
/** Verrouillage central par la voie d'origine : > 0 = verrouillé. -1 si illisible. */
2812+
internal fun readCentralLockRaw(): Int = when {
2813+
isA9Vsm() -> (callVsm("getZonedVehicleLockState") as? Int) ?: -1
2814+
FirmwareInfo.isVsmBased() -> (callVcontrol("getDoorLock") as? Int) ?: -1
2815+
else -> getIntPropertyVpm(VPM_VEHICLE_LOCK)
2816+
}
2817+
27622818
// -------------------------------------------------------------------------
27632819
// Chauffage intelligent de la batterie (« Intelligent battery heating »)
27642820
//
@@ -3767,6 +3823,12 @@ object MG4Hardware {
37673823
sb.appendLine()
37683824
}
37693825

3826+
// Sonde verrouillage (provisoire) : relevés des ouvertures du Diagnostic, journal de l'essai.
3827+
LockProbe.reports.forEach {
3828+
sb.appendLine(it)
3829+
sb.appendLine()
3830+
}
3831+
37703832
if (gen == FirmwareInfo.Gen.SWI132) {
37713833

37723834
// ── Binder IVehicleSettingService ─────────────────────────────
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.mg4.control.model
2+
3+
/**
4+
* Réglage d'origine « Lock when leaving » (`COMFORT_LOCK`, 0x2140d710) — la partie sans véhicule
5+
* de l'écran d'essai ([com.mg4.control.hardware.LockProbe]). PROVISOIRE, comme la sonde.
6+
*
7+
* Les écrans de réglages SWI133/68/165 portent un interrupteur « verrouiller en s'éloignant avec
8+
* la clé », qu'ils masquent quand la voiture répond 3. Le service d'origine ne filtre rien : ce
9+
* 3 vient de la voiture. L'essai envoie quand même la consigne de l'interrupteur et lit la
10+
* réponse — d'où [verdict], qui ne doit pas prendre un refus pour un succès.
11+
*/
12+
object LeaveLock {
13+
14+
/** Consigne de l'interrupteur d'origine : l'état voulu, pas une bascule. */
15+
const val SET_OFF = 0
16+
const val SET_ON = 1
17+
18+
/** La voiture dit ne pas avoir la fonction : l'écran d'origine masque alors la ligne. */
19+
private const val UNAVAILABLE = 3
20+
/** État du chargeur de téléphone sans fil quand la clé n'est plus détectée (SWI133, 2026-10-11). */
21+
private const val CHARGER_KEY_AWAY = 8
22+
23+
/** Une valeur suivie par l'écran d'essai, telle qu'il l'affiche. */
24+
data class Reading(val name: String, val value: String)
25+
26+
/** Suite d'une consigne, d'après la relecture par la voie d'origine. */
27+
enum class Verdict(val text: String) {
28+
ACCEPTED_ON("Consigne acceptée : la voiture répond « actif ». Reste à s\u2019éloigner avec la clé, " +
29+
"portes fermées, pour voir si elle verrouille."),
30+
ACCEPTED_OFF("Consigne acceptée : la voiture répond « coupé »."),
31+
IGNORED("Consigne ignorée : la voiture répond toujours « indisponible »."),
32+
NOT_REFLECTED("Consigne non reflétée : la voiture ne répond pas l\u2019état demandé."),
33+
UNREADABLE("Relecture impossible : rien à conclure."),
34+
}
35+
36+
/** Valeur rendue par une voie d'origine, ou null quand elle signale une lecture impossible. */
37+
fun raw(value: Int): Int? = value.takeIf { it != -1 }
38+
39+
/** Sens d'un état lu, d'après l'écran de réglages d'origine (`updateLeaveAutoLock`). */
40+
fun describe(value: Int?): String = when (value) {
41+
null -> "illisible"
42+
SET_OFF -> "0 = coupé"
43+
SET_ON -> "1 = actif"
44+
UNAVAILABLE -> "3 = indisponible (ligne masquée par l\u2019origine)"
45+
else -> "$value = valeur inconnue du code d\u2019origine"
46+
}
47+
48+
/** Ce que la relecture [after] dit de la consigne [on]. */
49+
fun verdict(on: Boolean, after: Int?): Verdict = when (after) {
50+
null -> Verdict.UNREADABLE
51+
UNAVAILABLE -> Verdict.IGNORED
52+
SET_ON -> if (on) Verdict.ACCEPTED_ON else Verdict.NOT_REFLECTED
53+
SET_OFF -> if (on) Verdict.NOT_REFLECTED else Verdict.ACCEPTED_OFF
54+
else -> Verdict.NOT_REFLECTED
55+
}
56+
57+
/** Verrouillage central (`VEHICLE_LOCK`) : la tuile d'origine est allumée dès que la valeur dépasse 0. */
58+
fun lockState(value: Int?): String = when {
59+
value == null -> "illisible"
60+
value > 0 -> "$value = verrouillé"
61+
else -> "$value = déverrouillé"
62+
}
63+
64+
/** État du chargeur de téléphone sans fil, seul témoin connu de la présence de la clé. */
65+
fun keyState(value: Int?): String = when (value) {
66+
null -> "illisible"
67+
CHARGER_KEY_AWAY -> "$value = clé non détectée"
68+
else -> value.toString()
69+
}
70+
71+
/**
72+
* Valeurs qui diffèrent entre deux lectures, dans l'ordre de l'écran. Une valeur absente de
73+
* la lecture précédente est ignorée : une première lecture n'est pas un changement.
74+
*/
75+
fun changes(before: List<Reading>, after: List<Reading>): List<String> {
76+
val avant = before.associate { it.name to it.value }
77+
return after.mapNotNull { lecture ->
78+
val ancienne = avant[lecture.name] ?: return@mapNotNull null
79+
if (ancienne == lecture.value) null else "${lecture.name} : $ancienne \u2192 ${lecture.value}"
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)