Skip to content

Commit 37e4c2a

Browse files
committed
move window feature to automation
1 parent 85ab657 commit 37e4c2a

21 files changed

Lines changed: 1190 additions & 1258 deletions

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

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,11 +1815,31 @@ object MG4Hardware {
18151815
val bean = if (family == CustomDriveScale.Family.VSM_68) customFromBean(method) else null
18161816
// La première voie qui rend une position connue l'emporte ; -1 est le « je ne sais pas »
18171817
// commun aux deux (service non lié côté SDK, exception côté VPM).
1818-
val reponses = listOfNotNull(direct, bean).filter { it >= 0 }
1819-
reponses.forEach { raw ->
1820-
CustomDriveScale.index(setting, raw, family)?.let { return CustomSetting.Known(it) }
1818+
listOf("directe" to direct, "instantané" to bean).forEach { (nom, raw) ->
1819+
if (raw == null || raw < 0) return@forEach
1820+
CustomDriveScale.index(setting, raw, family)?.let {
1821+
logCustomSource(setting, nom, direct, bean)
1822+
return CustomSetting.Known(it)
1823+
}
18211824
}
1822-
return reponses.firstOrNull()?.let { CustomSetting.Unknown(it) } ?: CustomSetting.Unavailable
1825+
val repondu = listOfNotNull(direct, bean).firstOrNull { it >= 0 }
1826+
return repondu?.let { CustomSetting.Unknown(it) } ?: CustomSetting.Unavailable
1827+
}
1828+
1829+
/**
1830+
* Une ligne par réglage et par démarrage : quelle voie a répondu, et ce que rendaient les
1831+
* autres. C'est le seul moyen de savoir laquelle garder — une fois les six firmwares passés,
1832+
* les voies inutiles et ce journal partent ensemble.
1833+
*/
1834+
private val sCustomSourceSeen =
1835+
java.util.Collections.synchronizedSet(mutableSetOf<CustomDriveScale.Setting>())
1836+
1837+
private fun logCustomSource(
1838+
setting: CustomDriveScale.Setting, retenue: String, direct: Int?, bean: Int?
1839+
) {
1840+
if (!sCustomSourceSeen.add(setting)) return
1841+
AppLogger.i(CUSTOM_TAG, "SOURCE $setting : voie $retenue retenue — " +
1842+
"directe=${direct ?: "muette"} · instantané=${bean ?: "n/a"} (${customFamily()})")
18231843
}
18241844

18251845
/** Puissance du mode Personnalisé. [index] : 0=Éco, 1=Normal, 2=Sport. */
@@ -1945,15 +1965,28 @@ object MG4Hardware {
19451965
*/
19461966
fun getDriveMode(): DriveMode? {
19471967
val rawCpm = getIntPropertyCPM(PROP_DRIVE_MODE, AREA_GLOBAL)
1948-
DriveMode.fromValueOrNull(rawCpm)?.let { return it }
1968+
DriveMode.fromValueOrNull(rawCpm)?.let {
1969+
logDriveModeSource("propriété", rawCpm, null)
1970+
return it
1971+
}
19491972
val rawSdk = (callVsm(driveModeGetter()) as? Int)
19501973
val mode = rawSdk?.let { DriveMode.fromValueOrNull(it) }
1951-
if (mode == null && logEnabled)
1974+
if (mode != null) logDriveModeSource("SDK ${driveModeGetter()}", rawCpm, rawSdk)
1975+
else if (logEnabled)
19521976
AppLogger.d(TAG, " mode de conduite illisible : CPM=$rawCpm " +
19531977
"${driveModeGetter()}=${rawSdk ?: "muet"}")
19541978
return mode
19551979
}
19561980

1981+
/** Idem pour le mode de conduite : une ligne au premier succès, puis silence. */
1982+
private val sDriveModeSourceLogged = java.util.concurrent.atomic.AtomicBoolean(false)
1983+
1984+
private fun logDriveModeSource(retenue: String, rawCpm: Int, rawSdk: Int?) {
1985+
if (sDriveModeSourceLogged.getAndSet(true)) return
1986+
AppLogger.i(CUSTOM_TAG, "SOURCE mode de conduite : voie $retenue retenue — " +
1987+
"CPM 0x${PROP_DRIVE_MODE.toString(16)}=$rawCpm · ${driveModeGetter()}=${rawSdk ?: "non interrogé"}")
1988+
}
1989+
19571990
/** Méthode du SDK qui rend le mode de conduite (l'A9 la nomme autrement). */
19581991
private fun driveModeGetter(): String =
19591992
if (customFamily() == CustomDriveScale.Family.A9) "getDrivingMode" else "getDriveMode"

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ object PowerWindows {
130130
}
131131

132132
/** Enregistre la calibration ; l'assistant finit sur une montée complète, la vitre est donc fermée. */
133+
/**
134+
* Vitres sans capteur dont la calibration manque encore.
135+
*
136+
* Sert de garde-fou à la fermeture automatique : elle ferme au temps mesuré, une vitre non
137+
* calibrée partirait sur la course par défaut sans que personne puisse le vérifier depuis
138+
* l'extérieur de la voiture.
139+
*/
140+
fun uncalibrated(context: Context): List<PowerWindow> =
141+
PowerWindow.entries.filter { !it.hasPositionSensor && calibration(context, it) == null }
142+
133143
fun saveCalibration(context: Context, window: PowerWindow, cal: WindowCalibration) {
134144
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
135145
putLong(calKey(window, "down_ms"), cal.downMs)

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

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.mg4.control.hardware
22

33
import android.content.Context
4+
import android.media.AudioManager
5+
import android.media.ToneGenerator
46
import android.os.Handler
57
import android.os.Looper
68
import android.os.SystemClock
@@ -35,9 +37,21 @@ object WindowAutoClose {
3537
private const val KEY_TIME_MIN = "win_autoclose_time_min"
3638
private const val KEY_BOTH = "win_autoclose_both"
3739
private const val KEY_DELAY_S = "win_autoclose_delay_s"
40+
private const val KEY_BEEP = "win_autoclose_beep"
41+
private const val KEY_BEEP_VOLUME = "win_autoclose_beep_volume"
3842

3943
private const val TICK_MS = 1_000L
4044

45+
/** Bip d'avertissement, repris de winclose : un par seconde pendant le délai. */
46+
private const val BEEP_MS = 150
47+
/** Volume du [ToneGenerator], en pour cent. Le minimum n'est pas 0 : couper, c'est décocher. */
48+
const val BEEP_VOLUME_MIN = 10
49+
const val BEEP_VOLUME_MAX = 100
50+
const val BEEP_VOLUME_STEP = 5
51+
const val BEEP_VOLUME_DEFAULT = 80
52+
/** Bip d'essai hors surveillance : on rend la piste audio peu après. */
53+
private const val PREVIEW_RELEASE_MS = 2_000L
54+
4155
enum class Result { PENDING, DONE, PARTIAL, CANCELLED }
4256

4357
data class Status(
@@ -58,6 +72,8 @@ object WindowAutoClose {
5872
val timeMin: Int,
5973
val requireBoth: Boolean,
6074
val delayS: Int,
75+
val beep: Boolean,
76+
val beepVolume: Int,
6177
) {
6278
fun toArming() = Arming(
6379
speedKmh = if (speedOn) speedKmh.toFloat() else null,
@@ -71,6 +87,12 @@ object WindowAutoClose {
7187
private var running = false
7288
private var lastResult: Result? = null
7389
private var lastResultAt = 0L
90+
/** Recopié depuis les réglages : le tic ne doit pas relire les préférences chaque seconde. */
91+
private var beepOn = false
92+
private var beepVolume = BEEP_VOLUME_DEFAULT
93+
private var tone: ToneGenerator? = null
94+
/** Volume du générateur en place : il se fixe à la construction, pas à l'appel. */
95+
private var toneVolume = -1
7496

7597
fun isEnabled(context: Context): Boolean =
7698
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false)
@@ -94,6 +116,11 @@ object WindowAutoClose {
94116
requireBoth = p.getBoolean(KEY_BOTH, false),
95117
delayS = p.getInt(KEY_DELAY_S, WindowAutoCloseTrigger.DELAY_DEFAULT_S)
96118
.coerceIn(WindowAutoCloseTrigger.DELAY_MIN_S, WindowAutoCloseTrigger.DELAY_MAX_S),
119+
// Décoché par défaut : le bip est une gêne pour qui n'en veut pas, et la fermeture
120+
// automatique est déjà une option qu'on active en connaissance de cause.
121+
beep = p.getBoolean(KEY_BEEP, false),
122+
beepVolume = p.getInt(KEY_BEEP_VOLUME, BEEP_VOLUME_DEFAULT)
123+
.coerceIn(BEEP_VOLUME_MIN, BEEP_VOLUME_MAX),
97124
)
98125
}
99126

@@ -106,6 +133,8 @@ object WindowAutoClose {
106133
putInt(KEY_TIME_MIN, s.timeMin)
107134
putBoolean(KEY_BOTH, s.requireBoth)
108135
putInt(KEY_DELAY_S, s.delayS)
136+
putBoolean(KEY_BEEP, s.beep)
137+
putInt(KEY_BEEP_VOLUME, s.beepVolume)
109138
}
110139
main.post { apply(s) }
111140
AppLogger.i(TAG, "fermeture auto : réglages = ${describe(s)}")
@@ -122,6 +151,9 @@ object WindowAutoClose {
122151
private fun apply(s: Settings) {
123152
trigger.arming = s.toArming()
124153
trigger.delayMs = s.delayS * 1000L
154+
beepOn = s.beep
155+
beepVolume = s.beepVolume
156+
if (!beepOn) releaseTone()
125157
}
126158

127159
private fun describe(s: Settings): String {
@@ -131,7 +163,8 @@ object WindowAutoClose {
131163
)
132164
val arming = if (parts.isEmpty()) "aucune condition (jamais armée)"
133165
else parts.joinToString(if (s.requireBoth) " ET " else " OU ")
134-
return "armement $arming, délai ${s.delayS} s"
166+
val bip = if (s.beep) "oui à ${s.beepVolume} %" else "non"
167+
return "armement $arming, délai ${s.delayS} s, bip $bip"
135168
}
136169

137170
// ── Surveillance ────────────────────────────────────────────────────────
@@ -163,6 +196,7 @@ object WindowAutoClose {
163196
running = false
164197
main.removeCallbacks(tick)
165198
ReadyWatcher.remove(readyListener)
199+
releaseTone()
166200
if (trigger.cancelPending()) {
167201
setResult(Result.CANCELLED)
168202
AppLogger.i(TAG, "fermeture auto : attente abandonnée (option désactivée)")
@@ -210,16 +244,67 @@ object WindowAutoClose {
210244

211245
private fun handle(outcome: WindowAutoCloseTrigger.Outcome) {
212246
when (outcome.action) {
213-
Action.SCHEDULE -> setResult(Result.PENDING)
247+
Action.SCHEDULE -> {
248+
setResult(Result.PENDING)
249+
beep()
250+
}
214251
Action.CANCEL -> {
215252
setResult(Result.CANCELLED)
253+
releaseTone()
216254
AppLogger.i(TAG, "fermeture auto : annulée (${outcome.reason})")
217255
}
218-
Action.CLOSE -> closeAll(outcome.reason)
219-
Action.NONE -> Unit
256+
Action.CLOSE -> {
257+
releaseTone()
258+
closeAll(outcome.reason)
259+
}
260+
// Rien à faire, sauf le bip de chaque seconde d'attente.
261+
Action.NONE -> if (trigger.isPending) beep()
262+
}
263+
}
264+
265+
/**
266+
* Un bip par seconde pendant le délai, comme winclose : le seul avertissement possible pour
267+
* qui est à côté de la voiture, l'écran ne se voyant pas de l'extérieur. Décoché par défaut.
268+
* Un échec (piste audio occupée, flux refusé) ne doit jamais empêcher la fermeture.
269+
*/
270+
private fun beep() {
271+
if (beepOn) playBeep(beepVolume)
272+
}
273+
274+
/**
275+
* Bip d'essai pendant que l'utilisateur déplace le curseur : régler un volume à l'aveugle
276+
* n'aurait pas de sens. Muet si l'avertissement est décoché.
277+
*/
278+
fun previewBeep(context: Context) {
279+
val s = settings(context)
280+
if (!s.beep) return
281+
playBeep(s.beepVolume)
282+
// Hors surveillance, personne ne viendra rendre la piste audio : on s'en charge.
283+
if (!running) {
284+
main.removeCallbacks(releasePreview)
285+
main.postDelayed(releasePreview, PREVIEW_RELEASE_MS)
220286
}
221287
}
222288

289+
private val releasePreview = Runnable { if (!running) releaseTone() }
290+
291+
private fun playBeep(volume: Int) {
292+
runCatching {
293+
// Le volume est figé à la construction : un réglage déplacé impose un nouveau générateur.
294+
if (toneVolume != volume) releaseTone()
295+
val t = tone ?: ToneGenerator(AudioManager.STREAM_NOTIFICATION, volume)
296+
.also { tone = it; toneVolume = volume }
297+
t.startTone(ToneGenerator.TONE_PROP_BEEP, BEEP_MS)
298+
}.onFailure { AppLogger.w(TAG, "fermeture auto : bip impossible (${it.javaClass.simpleName}: ${it.message})") }
299+
}
300+
301+
/** Le générateur tient une piste audio : on le rend dès que l'attente est finie. */
302+
private fun releaseTone() {
303+
tone?.release()
304+
tone = null
305+
toneVolume = -1
306+
}
307+
223308
private fun closeAll(reason: String) {
224309
val context = MG4Hardware.appContext()
225310
if (context != null && GarageMode.isOn(context)) {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ import com.mg4.control.model.DriveMode
3131
import com.mg4.control.bluetooth.BluetoothProfileManager
3232
import com.mg4.control.debug.AppLogger
3333
import com.mg4.control.hardware.MG4Hardware
34+
import com.mg4.control.hardware.PowerWindows
3435
import com.mg4.control.hardware.WindowAutoClose
3536
import com.mg4.control.hardware.MG4Hardware.AebMode
3637
import com.mg4.control.hardware.MG4Hardware.Swi68Mode
3738
import com.mg4.control.model.RegenLevel
39+
import com.mg4.control.model.WindowCommand
3840
import com.mg4.control.profile.ActiveProfile
3941
import com.mg4.control.profile.ProfileApplier
4042
import com.mg4.control.profile.ProfileManager
@@ -920,6 +922,23 @@ class MG4ControlService : Service() {
920922
return
921923
}
922924

925+
// Vitres : action directe, aucune bascule — une pression = une course complète.
926+
// La FERMETURE emprunte le chemin de la fermeture automatique, le seul qui aille au bout
927+
// sans personne devant l'écran : au volant, l'utilisateur ne regarde pas l'application.
928+
if (action == ShortcutAction.WINDOWS_OPEN_ALL || action == ShortcutAction.WINDOWS_CLOSE_ALL) {
929+
val ouvrir = action == ShortcutAction.WINDOWS_OPEN_ALL
930+
AppLogger.i(TAG, "SHORTCUT vitres : tout ${if (ouvrir) "ouvrir" else "fermer"}")
931+
PowerWindows.prepare()
932+
if (ouvrir) {
933+
PowerWindows.autoAll(WindowCommand.Direction.DOWN)
934+
} else {
935+
PowerWindows.closeAllAutomatically { ok ->
936+
AppLogger.i(TAG, "SHORTCUT vitres : fermeture ${if (ok) "complète" else "incomplète"}")
937+
}
938+
}
939+
return
940+
}
941+
923942
// VEHICLE_POWER_OFF : check P → confirmation (overlay) → extinction. Sinon message "en P".
924943
if (action == ShortcutAction.VEHICLE_POWER_OFF) {
925944
showVehiclePowerOffConfirm()

‎app/src/main/java/com/mg4/control/shortcut/ShortcutAction.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ enum class ShortcutAction(val id: Int) {
4949
// Volume : réglage du VÉHICULE, contrairement aux trois précédentes. Il ne dépend d'aucune
5050
// application et fonctionne donc même quand les touches média restent sans effet.
5151
VOLUME_UP(34),
52-
VOLUME_DOWN(35);
52+
VOLUME_DOWN(35),
53+
54+
// Vitres : actions directes, sans état à suivre — chaque pression lance la course complète
55+
// des quatre vitres. La voiture ne renvoyant rien sur leur position (hors vitre conducteur),
56+
// une bascule mémorisée n'aurait rien à quoi se raccrocher.
57+
WINDOWS_OPEN_ALL(36),
58+
WINDOWS_CLOSE_ALL(37);
5359

5460
companion object {
5561
fun fromId(id: Int) = entries.firstOrNull { it.id == id } ?: NONE

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class AutomationFragment : Fragment() {
2626

2727
private var profiles: List<DrivingProfile> = emptyList()
2828

29+
/** Carte des vitres : le panneau survit à la vue, comme au tableau de bord avant le déplacement. */
30+
private val windowsPanel = WindowsPanel()
31+
2932
override fun onCreateView(
3033
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
3134
): View = inflater.inflate(R.layout.fragment_automation, container, false)
@@ -51,6 +54,7 @@ class AutomationFragment : Fragment() {
5154
prefs.edit().putBoolean(AutomationSettings.KEY_ENABLED, checked).apply()
5255
}
5356
bindExpander(view.findViewById(R.id.btn_automation_expand), rowConfig, expanded = enabled)
57+
bindWindowsCard(view)
5458

5559
fun commitTemp() {
5660
val clamped = AutomationSettings.clampTemp(inputTemp.text.toString().toIntOrNull())
@@ -286,6 +290,9 @@ class AutomationFragment : Fragment() {
286290
val prefs = requireContext().getSharedPreferences(AutomationSettings.PREFS, Context.MODE_PRIVATE)
287291
setupSpinner(sp, prefs)
288292
}
293+
// Carte des vitres : le sondage ne reprend que si elle est restée dépliée.
294+
if (view?.findViewById<View>(R.id.row_windows_config)?.visibility == View.VISIBLE)
295+
windowsPanel.onShown()
289296
}
290297

291298
private fun setupSpinner(spinner: Spinner, prefs: android.content.SharedPreferences) {
@@ -319,14 +326,38 @@ class AutomationFragment : Fragment() {
319326
* en repliant la carte. L'etat initial suit quand meme l'activation — une automatisation
320327
* eteinte s'ouvre repliee, ce qui reproduit le comportement precedent.
321328
*/
322-
private fun bindExpander(btn: MaterialButton, content: View, expanded: Boolean) {
329+
private fun bindExpander(
330+
btn: MaterialButton, content: View, expanded: Boolean, onToggle: ((Boolean) -> Unit)? = null
331+
) {
323332
var open = expanded
324333
fun apply() {
325334
content.visibility = if (open) View.VISIBLE else View.GONE
326335
btn.text = if (open) "▾" else "▸" // chevron bas / droite
336+
onToggle?.invoke(open)
327337
}
328338
apply()
329339
btn.setOnClickListener { open = !open; apply() }
330340
}
331341

342+
/**
343+
* Carte des vitres, repliée par défaut : la commande manuelle et la calibration ne sont pas
344+
* des automatismes, on ne les ouvre que quand on les cherche.
345+
*
346+
* Le sondage des positions (et donc l'abonnement véhicule) ne tourne que carte ouverte ET
347+
* écran au premier plan — ailleurs il consommerait pour une valeur que personne ne regarde.
348+
*/
349+
private fun bindWindowsCard(view: View) {
350+
windowsPanel.bind(view)
351+
val content = view.findViewById<View>(R.id.row_windows_config)
352+
bindExpander(view.findViewById(R.id.btn_windows_expand), content, expanded = false) { open ->
353+
if (open && isResumed) windowsPanel.onShown() else windowsPanel.onHidden()
354+
}
355+
}
356+
357+
/** Écran quitté : plus de sondage, et aucune vitre ne reste en mouvement sans surveillance. */
358+
override fun onPause() {
359+
super.onPause()
360+
windowsPanel.onHidden()
361+
}
362+
332363
}

0 commit comments

Comments
 (0)