Skip to content

Commit 910a74c

Browse files
committed
add screen and capture for statistics
1 parent 0f071ba commit 910a74c

13 files changed

Lines changed: 265 additions & 30 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ object EnergyProbe {
104104
Signal("Odomètre AOSP", 0x11600204, null),
105105
Signal("Kilométrage total SAIC", 0x21401566, null),
106106
// Candidats à un odomètre plus fin que le kilomètre entier : le premier est dans la liste
107-
// énumérée par le gestionnaire BMS, les deux autres viennent du combiné.
107+
// énumérée par le gestionnaire BMS, les deux autres viennent du combiné. ⚠️ Tous à ZÉRO sur
108+
// SWI133 (relevé du 2026-09-20) — d'où l'intégration de la vitesse dans StatsTracker. Gardés
109+
// pour vérifier si un firmware A9 répond, eux.
108110
Signal("Odomètre BMS", 0x2140f447, BMS_SERVICE, validity = 0x2120f448),
109111
Signal("Odomètre combiné", 0x21407b13, null),
110112
Signal("Trajet combiné : départ", 0x21407b86, null),

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ object EnergyReader {
8282
// Pas de propriété de puissance : c'est le produit, le courant étant négatif en charge.
8383
powerKw = if (volts != null && amperes != null) -volts * amperes / 1000f else null,
8484
rangeKm = int(PROP_RANGE)?.takeIf { it in 1..1500 },
85+
// Déjà en km/h chez SAIC malgré la spec AOSP : voir MG4Hardware.getVehicleSpeedKmh().
86+
speedKmh = MG4Hardware.getVehicleSpeedKmh()?.takeIf { it in 0f..300f },
8587
outsideTempC = MG4Hardware.getOutsideTempCelsius(),
8688
)
8789
}
@@ -90,6 +92,10 @@ object EnergyReader {
9092
* Capacité de la batterie annoncée par le véhicule, en kWh — `null` si la propriété ne répond
9193
* pas ou rend une valeur invraisemblable.
9294
*
95+
* **La MG4 ne la publie pas** : sondée le 2026-09-20 sur SWI133, la propriété rend 0,0. Le
96+
* réglage manuel reste donc la seule source. La lecture est gardée parce qu'elle ne coûte rien
97+
* et que les firmwares A9 pourraient, eux, répondre.
98+
*
9399
* AOSP la donne en Wh ; certains véhicules la publient déjà en kWh, d'où les deux échelles
94100
* acceptées. Elle sert de valeur par défaut au réglage de l'onglet Statistiques : c'est mieux
95101
* qu'un 62 arbitraire, sans priver l'utilisateur du dernier mot — la valeur annoncée peut être

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ object StatsCollector {
2727
/** Assez fin pour dater un trajet à la demi-minute, assez lâche pour ne rien coûter. */
2828
private const val TICK_MS = 30_000L
2929

30+
/**
31+
* Cadence en roulant. La distance vient de l'intégration de la vitesse, faute d'odomètre plus
32+
* fin que le kilomètre : à trente secondes, un trapèze couvrirait un demi-kilomètre de route
33+
* sur une seule droite, et l'intégration ne ferait pas mieux que l'odomètre qu'elle remplace.
34+
*/
35+
private const val TICK_DRIVING_MS = 10_000L
36+
3037
private val thread = HandlerThread("mg4-stats").apply { start() }
3138
private val worker = Handler(thread.looper)
3239

@@ -41,13 +48,26 @@ object StatsCollector {
4148
* boîtier s'apprête à couper l'application. Attendre le tic suivant, trente secondes plus tard,
4249
* revenait à jouer le trajet à pile ou face : on relève donc **immédiatement** au changement.
4350
*/
44-
private val readyListener = ReadyWatcher.Listener { _, _ -> worker.post { sample() } }
51+
private val readyListener = ReadyWatcher.Listener { _, _ ->
52+
worker.post {
53+
if (running) {
54+
sample()
55+
// La cadence dépend du contact : on la réarme tout de suite au lieu d'attendre
56+
// un tic qui peut être à trente secondes.
57+
worker.removeCallbacks(tick)
58+
worker.postDelayed(tick, interval())
59+
}
60+
}
61+
}
62+
63+
private fun interval(): Long =
64+
if (ReadyWatcher.ready == true) TICK_DRIVING_MS else TICK_MS
4565

4666
private val tick = object : Runnable {
4767
override fun run() {
4868
if (!running) return
4969
sample()
50-
worker.postDelayed(this, TICK_MS)
70+
worker.postDelayed(this, interval())
5171
}
5272
}
5373

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ data class EnergySnapshot(
3333
val powerKw: Float? = null,
3434
/** Autonomie annoncée par la voiture, en kilomètres. */
3535
val rangeKm: Int? = null,
36+
/**
37+
* Vitesse instantanée en km/h, toujours positive.
38+
*
39+
* C'est la seule voie vers une distance plus fine que le kilomètre : sondés le 2026-09-20,
40+
* l'odomètre AOSP, celui du combiné et la carte de trajet rendent tous zéro sur SWI133.
41+
*/
42+
val speedKmh: Float? = null,
3643
/** Température extérieure en °C, pour expliquer une consommation. */
3744
val outsideTempC: Float? = null,
3845
) {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ data class PendingTrip(
2323
val climateLast: Float?,
2424
val accessoriesLast: Float?,
2525
val regenLast: Float?,
26+
/** Distance accumulée en intégrant la vitesse, en km. Voir [Trip.integratedKm]. */
27+
val integratedKm: Float = 0f,
28+
/** Vitesse du relevé précédent, l'autre côté du trapèze. */
29+
val lastSpeedKmh: Float? = null,
30+
/**
31+
* Vrai dès qu'un intervalle a pu être intégré. Sans ce drapeau, une distance de 0 km ne se
32+
* distinguerait pas d'une distance jamais mesurée — et l'écran annoncerait un trajet immobile.
33+
*/
34+
val integrated: Boolean = false,
2635
)
2736

2837
data class PendingCharge(

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

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.mg4.control.model
22

3+
import kotlin.math.abs
34
import kotlin.math.max
45
import kotlin.math.roundToInt
56

@@ -12,6 +13,10 @@ import kotlin.math.roundToInt
1213
data class Trip(
1314
val startMs: Long,
1415
val endMs: Long,
16+
/**
17+
* Distance lue sur l'odomètre du véhicule, **au kilomètre entier** — c'est tout ce qu'il
18+
* publie. Elle sert de contrôle à [integratedKm], plus fine mais susceptible de dériver.
19+
*/
1520
val distanceKm: Int,
1621
/**
1722
* Énergie **brute** relevée sur le compteur du véhicule : la régénération n'en est PAS déduite.
@@ -27,9 +32,36 @@ data class Trip(
2732
val socStart: Float?,
2833
val socEnd: Float?,
2934
val outsideTempC: Float?,
35+
/**
36+
* Distance obtenue en intégrant la vitesse, au dixième de kilomètre.
37+
*
38+
* L'odomètre du véhicule est entier et aucune autre source n'a répondu (sondés le 2026-09-20 :
39+
* odomètre AOSP, odomètre du combiné, carte de trajet — tous à zéro). Null sur les trajets
40+
* enregistrés avant cette mesure, et null si la vitesse n'a jamais pu être relevée.
41+
*/
42+
val integratedKm: Float? = null,
3043
) {
3144
val durationMs: Long get() = max(0L, endMs - startMs)
3245

46+
/**
47+
* Distance retenue pour l'affichage et les ratios.
48+
*
49+
* L'intégration l'emporte quand elle existe ET qu'elle concorde avec l'odomètre : elle est dix
50+
* fois plus fine. En cas de désaccord franc — plus de 2 km ou 15 % —, on retombe sur
51+
* l'odomètre : lui ne dérive jamais, et un tel écart trahirait des relevés manqués plutôt
52+
* qu'une meilleure mesure.
53+
*/
54+
val distance: Float
55+
get() {
56+
val integre = integratedKm ?: return distanceKm.toFloat()
57+
if (distanceKm <= 0) return integre
58+
val ecart = abs(integre - distanceKm)
59+
return if (ecart <= max(2f, distanceKm * 0.15f)) integre else distanceKm.toFloat()
60+
}
61+
62+
/** Vrai quand la distance affichée vient de l'intégration, donc connue au dixième. */
63+
val distancePrecise: Boolean get() = integratedKm != null && distance == integratedKm
64+
3365
/**
3466
* Énergie réellement sortie de la batterie : brute moins ce que la régénération a rendu.
3567
*
@@ -42,13 +74,13 @@ data class Trip(
4274
/**
4375
* Consommation moyenne en kWh/100 km, **null sous la distance plancher**.
4476
*
45-
* L'odomètre du véhicule est au kilomètre entier : sur 2 km, la distance est connue à ±50 %
46-
* près et le ratio n'aurait aucun sens. Mieux vaut ne rien afficher que d'afficher un chiffre
47-
* faux — l'énergie et la distance, elles, restent justes et restent affichées.
77+
* Le plancher dépend de la source. Au kilomètre entier, 2 km sont connus à ±50 % près et le
78+
* ratio n'aurait aucun sens ; au dixième, un seul kilomètre donne déjà un chiffre honnête.
79+
* Mieux vaut ne rien afficher qu'un chiffre faux — l'énergie et la distance, elles, restent
80+
* justes et restent affichées.
4881
*/
4982
val consumptionPer100: Float?
50-
get() = if (distanceKm >= MIN_DISTANCE_FOR_RATIO_KM && distanceKm > 0)
51-
netEnergyKwh * 100f / distanceKm else null
83+
get() = if (distance >= ratioFloor) netEnergyKwh * 100f / distance else null
5284

5385
/**
5486
* Énergie du moteur : ce qui reste du total une fois la climatisation et les accessoires
@@ -69,13 +101,19 @@ data class Trip(
69101
/** Vitesse moyenne en km/h, même réserve de précision que la consommation. */
70102
val averageSpeedKmh: Float?
71103
get() {
72-
if (distanceKm < MIN_DISTANCE_FOR_RATIO_KM || durationMs <= 0L) return null
73-
return distanceKm * 3_600_000f / durationMs
104+
if (distance < ratioFloor || durationMs <= 0L) return null
105+
return distance * 3_600_000f / durationMs
74106
}
75107

108+
private val ratioFloor: Float
109+
get() = if (distancePrecise) MIN_DISTANCE_PRECISE_KM else MIN_DISTANCE_ODOMETER_KM
110+
76111
companion object {
77-
/** En deçà, on n'affiche aucun ratio : l'odomètre est au kilomètre entier. */
78-
const val MIN_DISTANCE_FOR_RATIO_KM = 5
112+
/** Plancher des ratios quand la distance vient de l'odomètre, au kilomètre entier. */
113+
const val MIN_DISTANCE_ODOMETER_KM = 5f
114+
115+
/** Idem quand elle vient de l'intégration de la vitesse, au dixième. */
116+
const val MIN_DISTANCE_PRECISE_KM = 1f
79117
}
80118
}
81119

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ package com.mg4.control.model
1212
*/
1313
data class StatsSummary(
1414
val tripCount: Int,
15-
val distanceKm: Int,
15+
val distanceKm: Float,
1616
val energyKwh: Float,
1717
val regenKwh: Float,
18-
val longestTripKm: Int,
18+
val longestTripKm: Float,
1919
val drivingMs: Long,
2020
val chargeCount: Int,
2121
val chargedKwh: Float,
@@ -26,22 +26,29 @@ data class StatsSummary(
2626
val averagePricePerKwh: Float?,
2727
/** Coût estimé de l'énergie consommée en roulant. */
2828
val drivingCost: Float?,
29+
/**
30+
* Vrai si **tous** les trajets de la période ont une distance intégrée. Le plancher des ratios
31+
* en dépend : il serait incohérent qu'une ligne de trajet annonce une consommation que le
32+
* résumé de la même période refuse d'afficher.
33+
*/
34+
val precise: Boolean,
2935
) {
36+
private val floor: Float
37+
get() = if (precise) Trip.MIN_DISTANCE_PRECISE_KM else Trip.MIN_DISTANCE_ODOMETER_KM
38+
3039
/** kWh/100 km sur la période, null en dessous de la distance plancher. */
3140
val consumptionPer100: Float?
32-
get() = if (distanceKm >= Trip.MIN_DISTANCE_FOR_RATIO_KM)
33-
energyKwh * 100f / distanceKm else null
41+
get() = if (distanceKm >= floor) energyKwh * 100f / distanceKm else null
3442

3543
val averageSpeedKmh: Float?
36-
get() = if (distanceKm >= Trip.MIN_DISTANCE_FOR_RATIO_KM && drivingMs > 0L)
44+
get() = if (distanceKm >= floor && drivingMs > 0L)
3745
distanceKm * 3_600_000f / drivingMs else null
3846

3947
/** Coût aux 100 km, pour comparer avec un plein de carburant. */
4048
val costPer100: Float?
4149
get() {
4250
val cout = drivingCost ?: return null
43-
return if (distanceKm >= Trip.MIN_DISTANCE_FOR_RATIO_KM)
44-
cout * 100f / distanceKm else null
51+
return if (distanceKm >= floor) cout * 100f / distanceKm else null
4552
}
4653

4754
companion object {
@@ -56,10 +63,10 @@ data class StatsSummary(
5663
val energie = trips.sumOf { it.netEnergyKwh.toDouble() }.toFloat()
5764
return StatsSummary(
5865
tripCount = trips.size,
59-
distanceKm = trips.sumOf { it.distanceKm },
66+
distanceKm = trips.sumOf { it.distance.toDouble() }.toFloat().roundTenth(),
6067
energyKwh = energie.roundTenth(),
6168
regenKwh = trips.sumOf { (it.regenKwh ?: 0f).toDouble() }.toFloat().roundTenth(),
62-
longestTripKm = trips.maxOfOrNull { it.distanceKm } ?: 0,
69+
longestTripKm = trips.maxOfOrNull { it.distance } ?: 0f,
6370
drivingMs = trips.sumOf { it.durationMs },
6471
chargeCount = charges.size,
6572
chargedKwh = chargedKwh.roundTenth(),
@@ -70,6 +77,7 @@ data class StatsSummary(
7077
// Faute de charge sur la période, on retombe sur le tarif alternatif : c'est une
7178
// estimation, et l'écran l'annonce comme telle.
7279
drivingCost = energie * (prixMoyen ?: settings.priceAc),
80+
precise = trips.isNotEmpty() && trips.all { it.distancePrecise },
7381
)
7482
}
7583
}

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ package com.mg4.control.model
1616
* survit à un boîtier qui s'endort pendant la nuit ; la puissance mesurée, elle, n'existe que si
1717
* l'application a pu relever quelque chose.
1818
*
19+
* **Distance d'un trajet** : l'odomètre donne le kilomètre entier, et c'est tout ce que le
20+
* véhicule publie (les trois autres odomètres du firmware ont été sondés le 2026-09-20, tous à
21+
* zéro). La distance fine vient donc de l'intégration de la vitesse par la méthode des trapèzes ;
22+
* l'odomètre reste le garde-fou qui détecte une intégration partielle.
23+
*
1924
* **Survie aux coupures** : l'état courant est exposé par [pendingState] pour être enregistré après
2025
* chaque relevé, et [recover] rouvre ce qui restait en cours au démarrage suivant. Sans ça, tout
2126
* trajet dont la fin coïncide avec l'extinction du boîtier — c'est-à-dire la plupart — serait perdu.
@@ -71,6 +76,9 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
7176
// ── Trajet ──────────────────────────────────────────────────────────
7277
val enCours = trip
7378
if (ready == true) {
79+
// Calculé avant la recopie : il dit à la fois combien ajouter et si l'intervalle
80+
// comptait, ce qui n'est pas la même chose qu'ajouter zéro.
81+
val pas = enCours?.let { integrationStep(it, snapshot) }
7482
trip = if (enCours == null) PendingTrip(
7583
startMs = snapshot.timestampMs,
7684
odometerStart = snapshot.odometerKm,
@@ -84,6 +92,7 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
8492
climateLast = snapshot.climateSinceStartKwh,
8593
accessoriesLast = snapshot.accessoriesSinceStartKwh,
8694
regenLast = snapshot.regenSinceStartKwh,
95+
lastSpeedKmh = snapshot.speedKmh,
8796
) else enCours.copy(
8897
lastMs = snapshot.timestampMs,
8998
odometerLast = snapshot.odometerKm ?: enCours.odometerLast,
@@ -92,8 +101,23 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
92101
climateLast = snapshot.climateSinceStartKwh ?: enCours.climateLast,
93102
accessoriesLast = snapshot.accessoriesSinceStartKwh ?: enCours.accessoriesLast,
94103
regenLast = snapshot.regenSinceStartKwh ?: enCours.regenLast,
104+
integratedKm = enCours.integratedKm + (pas ?: 0f),
105+
integrated = enCours.integrated || pas != null,
106+
// Une vitesse manquée ne réinitialise pas le trapèze : le prochain intervalle
107+
// repart de la dernière vitesse connue, ce qui sous-estime sans jamais inventer.
108+
lastSpeedKmh = snapshot.speedKmh ?: enCours.lastSpeedKmh,
95109
)
96110
} else if (ready == false && enCours != null) {
111+
// Entre le dernier relevé sous contact et celui qui le coupe, la voiture roulait
112+
// encore : sans ce dernier trapèze, chaque trajet perdrait sa fin. Seules la distance
113+
// et l'heure de fin sont reprises — les compteurs d'énergie restent ceux du dernier
114+
// relevé sous contact, dont on sait qu'ils étaient valides.
115+
val fin = integrationStep(enCours, snapshot)
116+
if (fin != null) trip = enCours.copy(
117+
lastMs = snapshot.timestampMs,
118+
integratedKm = enCours.integratedKm + fin,
119+
integrated = true,
120+
)
97121
finishTrip()?.let { events += Event.TripEnded(it) }
98122
}
99123

@@ -127,6 +151,22 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
127151
return events
128152
}
129153

154+
/**
155+
* Distance parcourue depuis le relevé précédent, par la méthode des trapèzes.
156+
*
157+
* Rend null quand l'intervalle n'est pas mesurable : vitesse inconnue d'un côté ou de l'autre,
158+
* ou trou de plus de [MAX_SAMPLE_GAP_MS] — passé ce délai, prolonger la dernière vitesse connue
159+
* ne serait plus une mesure mais une supposition. L'intervalle est alors simplement perdu, et
160+
* la comparaison avec l'odomètre se chargera de dire que le total n'est plus fiable.
161+
*/
162+
private fun integrationStep(p: PendingTrip, s: EnergySnapshot): Float? {
163+
val avant = p.lastSpeedKmh ?: return null
164+
val apres = s.speedKmh ?: return null
165+
val dt = s.timestampMs - p.lastMs
166+
if (dt <= 0L || dt > MAX_SAMPLE_GAP_MS) return null
167+
return (avant + apres) / 2f * (dt / 3_600_000f)
168+
}
169+
130170
/**
131171
* Fin de trajet, à partir du dernier relevé observé. Rend null pour un trajet sans distance ni
132172
* énergie : mettre le contact pour régler la climatisation n'est pas un trajet, et remplirait
@@ -137,7 +177,10 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
137177
trip = null
138178
val distance = diffKm(p.odometerStart, p.odometerLast)
139179
val energy = counterDelta(p.energyStart, p.energyLast) ?: 0f
140-
if (distance <= 0 && energy <= 0f) return null
180+
// La distance intégrée compte dans ce test : un trajet de six cents mètres est un vrai
181+
// trajet, même si l'odomètre n'a pas changé de kilomètre.
182+
val integre = if (p.integrated) p.integratedKm.roundTenth() else null
183+
if (distance <= 0 && energy <= 0f && (integre ?: 0f) <= 0f) return null
141184
return Trip(
142185
startMs = p.startMs,
143186
endMs = p.lastMs,
@@ -149,6 +192,7 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
149192
socStart = p.socStart,
150193
socEnd = p.socLast,
151194
outsideTempC = p.tempC,
195+
integratedKm = integre,
152196
)
153197
}
154198

@@ -181,4 +225,13 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
181225
if (start == null || end < start) return end
182226
return end - start
183227
}
228+
229+
companion object {
230+
/**
231+
* Trou maximal accepté entre deux relevés pour intégrer la vitesse. Le collecteur relève
232+
* toutes les dix secondes en roulant : une minute laisse passer cinq relevés manqués, bien
233+
* au-delà d'un simple retard de l'ordonnanceur, mais sans couvrir une mise en veille.
234+
*/
235+
const val MAX_SAMPLE_GAP_MS = 60_000L
236+
}
184237
}

‎app/src/main/java/com/mg4/control/stats/StatsStore.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class StatsStore(private val context: Context) {
135135
fun addTrip(trip: Trip) = synchronized(LOCK) {
136136
val h = read()
137137
write(h.copy(trips = h.trips + trip))
138-
AppLogger.i(TAG, "trajet enregistré : ${trip.distanceKm} km · ${trip.energyKwh} kWh")
138+
AppLogger.i(TAG, "trajet enregistré : ${trip.distance} km · ${trip.netEnergyKwh} kWh")
139139
}
140140

141141
fun addCharge(session: ChargeSession) = synchronized(LOCK) {

0 commit comments

Comments
 (0)