|
| 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 | +} |
0 commit comments