Skip to content

Commit a82e288

Browse files
malysclaude
andcommitted
feat(chargepilot): CP-082 the charges screen, and the overnight charge that went missing
A night on the charger left the battery page saying no charge had been measured. EVHardware 3417b30 stops filing a slept-through charge as regeneration when the unrecorded drive home moved the odometer, and the battery page now reloads on a new ledger entry in stable too: its own bind starts the sampler whose first sample is the morning reading. Charge history lists every charge with duration, rate, outside temperature and, when watched, energy, mean and peak power, plus a measured charge curve by 10-point SOC band. No cost, no charger type, no guessed energy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7962c73 commit a82e288

9 files changed

Lines changed: 353 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **Charges — a list of every charge, and what the watched ones measured (CP-082).** The battery
10+
page gains a *Charge history* button. The screen lists every charge newest first — start and
11+
end, charge before and after, duration, mean points per hour, outside temperature range and,
12+
for a charge the app watched, the energy into the pack with its mean and peak power. The
13+
analysis pane adds the median charge, the fastest one, totals, the pack-current sign and the
14+
car-counter verdicts, and the **charge curve**: measured pack power per 10-point band of charge,
15+
which is where the taper above 80 % shows. No cost, no charger type, no capacity from the charge
16+
side — measured only. The exported `[charge_energy]` section carries the same per-charge peak,
17+
step count and bands.
18+
719
### Fixed
820

921
- **The eco voice can be heard, and checked.** The coach now waits for the vehicle voice service
@@ -12,6 +24,15 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
1224
voice nor an Android engine answers, the settings screen says so under the switch, and the
1325
diagnostic report carries `vehicle_voice_bound`.
1426

27+
- **An overnight charge now appears the next morning (CP-082).** Two causes. EVHardware
28+
`3417b30`: a rise across a night the head unit slept through was filed as regeneration whenever
29+
the odometer differed between the evening and morning entries, which it does as soon as the
30+
drive home went unrecorded; a rise is now a charge when the odometer stood still, the car
31+
reported a non-zero charging status, or it is at least 10 points and no one watched the car
32+
move. And the battery page loaded the ledger before the sampler it had just started wrote the
33+
morning reading that closes the charge; stable now reloads on a new ledger entry the way
34+
unstable already did.
35+
1536
### Added
1637

1738
- **Eco advice when the trip ends in P.** With advice and voice on, the car says what the trip

‎app/src/main/AndroidManifest.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@
128128
android:exported="false"
129129
android:screenOrientation="landscape" />
130130

131+
<activity
132+
android:name=".ChargeHistoryActivity"
133+
android:exported="false"
134+
android:screenOrientation="landscape" />
135+
131136
<activity
132137
android:name=".ChargeStopActivity"
133138
android:exported="false"

‎app/src/main/java/com/evsuite/chargepilot/BatteryHealthActivity.kt‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,17 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
6464
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
6565
val value = (service as? TripRecordingService.LocalBinder)?.service ?: return
6666
recorder = value
67-
if (ChargeLearningDefaults.AUTO_REFRESH) {
68-
loadedBatteryRevision = value.batteryRevision
69-
load()
70-
}
67+
// Every variant, not only unstable: binding this page is what starts the sampler
68+
// after a night asleep, and its first sample — the morning reading that closes the
69+
// overnight charge — lands after onCreate's load. Without this reload the page
70+
// showed the evening's ledger and said no charge had been measured. One ledger read
71+
// per point of charge moved, never a 1 Hz poll.
72+
loadedBatteryRevision = value.batteryRevision
73+
load()
7174
value.setListener(this@BatteryHealthActivity) { snapshot ->
7275
speedKmh = snapshot.speedKmh
7376
speedObservedAtMs = snapshot.timestampMs
74-
if (
75-
ChargeLearningDefaults.AUTO_REFRESH &&
76-
value.batteryRevision != loadedBatteryRevision
77-
) {
77+
if (value.batteryRevision != loadedBatteryRevision) {
7878
loadedBatteryRevision = value.batteryRevision
7979
load()
8080
}
@@ -98,6 +98,9 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
9898
binding = ActivityBatteryHealthBinding.inflate(layoutInflater)
9999
setContentView(binding.root)
100100
binding.applyAction.setOnClickListener { apply() }
101+
binding.chargeHistoryAction.setOnClickListener {
102+
startActivity(Intent(this, ChargeHistoryActivity::class.java))
103+
}
101104
load()
102105
}
103106

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.os.Bundle
4+
import androidx.appcompat.app.AppCompatActivity
5+
import com.evsuite.chargepilot.databinding.ActivityChargeHistoryBinding
6+
import com.evsuite.hardware.telemetry.ChargeEnergy
7+
import com.evsuite.hardware.telemetry.ChargeEnergyReport
8+
import java.text.DateFormat
9+
import java.util.Date
10+
import java.util.concurrent.Executors
11+
import kotlin.math.abs
12+
13+
/**
14+
* Every charge the battery ledger holds, and what the watched ones measured.
15+
*
16+
* The battery page has room for one line about charging; this screen is where the list lives.
17+
* Nothing new is sampled or inferred: rows come from [ChargeEnergyReport], the same report the
18+
* battery page and the diagnostic bundle read, so the three can never disagree. There is no
19+
* cost here on purpose — a tariff is a declaration, and every figure on this screen is measured.
20+
*
21+
* A charge the head unit slept through keeps its points and its span and shows no energy or
22+
* power: the integral did not move while nobody was integrating, and a zero would be a lie.
23+
*/
24+
class ChargeHistoryActivity : AppCompatActivity() {
25+
26+
private lateinit var binding: ActivityChargeHistoryBinding
27+
private val disk = Executors.newSingleThreadExecutor { runnable ->
28+
Thread(runnable, "chargepilot-charge-history")
29+
}
30+
31+
override fun onCreate(savedInstanceState: Bundle?) {
32+
super.onCreate(savedInstanceState)
33+
binding = ActivityChargeHistoryBinding.inflate(layoutInflater)
34+
setContentView(binding.root)
35+
binding.backAction.setOnClickListener { finish() }
36+
disk.execute {
37+
val settings = VehicleSettings.read(this)
38+
val report = LocalBatteryLearning.refresh(filesDir, settings.usableCapacityKwhWhenNew)
39+
.charge
40+
runOnUiThread {
41+
if (!isFinishing && !isDestroyed) render(report)
42+
}
43+
}
44+
}
45+
46+
override fun onDestroy() {
47+
disk.shutdownNow()
48+
super.onDestroy()
49+
}
50+
51+
private fun render(report: ChargeEnergyReport) {
52+
val charges = report.pluggedCharges
53+
if (charges.isEmpty()) {
54+
binding.analysisFacts.text = getString(R.string.charge_history_empty)
55+
binding.curveFacts.text = getString(R.string.charge_history_curve_empty)
56+
binding.chargeList.text = ""
57+
return
58+
}
59+
binding.analysisFacts.text = analysis(report, charges).joinToString("\n\n")
60+
val bands = report.powerBySocBand()
61+
binding.curveFacts.text = if (bands.isEmpty()) {
62+
getString(R.string.charge_history_curve_empty)
63+
} else {
64+
bands.joinToString("\n") {
65+
getString(
66+
R.string.charge_history_band,
67+
it.fromPercent, it.toPercent, it.meanPowerKw, it.hours,
68+
)
69+
}
70+
}
71+
binding.chargeList.text = charges.asReversed()
72+
.take(MAX_ROWS)
73+
.joinToString("\n\n") { row(it) }
74+
}
75+
76+
private fun analysis(report: ChargeEnergyReport, charges: List<ChargeEnergy>): List<String> {
77+
val lines = ArrayList<String>()
78+
lines += getString(
79+
R.string.charge_history_summary,
80+
charges.size,
81+
charges.count { it.watched },
82+
charges.sumOf { it.session.gainedPercent },
83+
)
84+
val median = charges.sortedBy { it.session.gainedPercent }[charges.size / 2].session
85+
lines += getString(
86+
R.string.charge_history_typical,
87+
median.gainedPercent,
88+
median.meanPercentPerHour,
89+
charges.maxOf { it.session.peakPercentPerHour },
90+
)
91+
val watched = charges.filter { it.watched && it.packDeltaKwh != null }
92+
if (watched.isNotEmpty()) {
93+
val energy = watched.sumOf { abs(it.packDeltaKwh!!) }
94+
val hours = watched.sumOf { it.session.durationHours }
95+
lines += getString(
96+
R.string.charge_history_energy,
97+
energy,
98+
if (hours > 0.0) energy / hours else 0.0,
99+
watched.mapNotNull { it.peakPowerKw }.maxOrNull() ?: 0.0,
100+
)
101+
}
102+
lines += getString(
103+
R.string.charge_history_verdicts,
104+
report.packSign.name,
105+
report.counterBehaviour.name,
106+
report.chargingStatuses.joinToString(",").ifEmpty { "—" },
107+
)
108+
lines += getString(R.string.charge_history_temperature)
109+
return lines
110+
}
111+
112+
private fun row(charge: ChargeEnergy): String {
113+
val session = charge.session
114+
val format = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
115+
val lines = ArrayList<String>()
116+
lines += getString(
117+
R.string.charge_history_row,
118+
format.format(Date(session.startedAtMs)),
119+
format.format(Date(session.endedAtMs)),
120+
session.startSocPercent,
121+
session.endSocPercent,
122+
session.gainedPercent,
123+
session.durationHours,
124+
session.meanPercentPerHour,
125+
)
126+
val energy = charge.packDeltaKwh?.takeIf { charge.watched }
127+
val power = charge.meanPowerKw
128+
lines += if (energy != null && power != null) {
129+
getString(
130+
R.string.charge_history_row_watched,
131+
abs(energy),
132+
power,
133+
charge.peakPowerKw ?: power,
134+
)
135+
} else {
136+
getString(R.string.charge_history_row_unwatched)
137+
}
138+
val low = charge.minOutsideTempCelsius
139+
val high = charge.maxOutsideTempCelsius
140+
if (low != null && high != null) {
141+
lines += getString(R.string.charge_history_row_temperature, low, high)
142+
}
143+
return lines.joinToString("\n")
144+
}
145+
146+
private companion object {
147+
/** The ledger is bounded already; a list longer than this is scrolled, not read. */
148+
const val MAX_ROWS = 50
149+
}
150+
}

‎app/src/main/res/layout/activity_battery_health.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@
8787
android:layout_marginTop="@dimen/spacing_sm"
8888
android:textAppearance="@style/Text.EV.Secondary" />
8989

90+
<com.google.android.material.button.MaterialButton
91+
android:id="@+id/chargeHistoryAction"
92+
style="@style/Widget.EV.Button.Outlined"
93+
android:layout_width="wrap_content"
94+
android:layout_height="@dimen/touch_target"
95+
android:layout_marginTop="@dimen/spacing_sm"
96+
android:text="@string/charge_history_open" />
97+
9098
<Space
9199
android:layout_width="match_parent"
92100
android:layout_height="0dp"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Impeccable direction contract
4+
THESIS: every charge the ledger holds, and what the watched ones measured, never a cost.
5+
OWN-WORLD: EVSuite tonal panes, one outline, one radius, Roboto readouts, no shadow or chart.
6+
STORY: read the charge curve and the verdicts, then scroll the charges newest first.
7+
FIRST VIEWPORT: the analysis anchors the left pane; the scrolling list owns the right.
8+
FORM: two-pane evidence ledger extending the battery page; seed key cp082-20260928.
9+
-->
10+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
11+
android:layout_width="match_parent"
12+
android:layout_height="match_parent"
13+
android:orientation="vertical">
14+
15+
<LinearLayout
16+
android:layout_width="match_parent"
17+
android:layout_height="@dimen/chargepilot_bar_height"
18+
android:background="@drawable/bg_topbar"
19+
android:gravity="center_vertical"
20+
android:orientation="horizontal"
21+
android:paddingStart="@dimen/spacing_lg"
22+
android:paddingEnd="@dimen/spacing_lg">
23+
24+
<TextView
25+
android:layout_width="0dp"
26+
android:layout_height="wrap_content"
27+
android:layout_weight="1"
28+
android:text="@string/charge_history_title"
29+
android:textAppearance="@style/Text.EV.Title" />
30+
31+
<com.google.android.material.button.MaterialButton
32+
android:id="@+id/backAction"
33+
style="@style/Widget.EV.Button.Outlined"
34+
android:layout_width="180dp"
35+
android:layout_height="match_parent"
36+
android:insetTop="0dp"
37+
android:insetBottom="0dp"
38+
android:text="@string/action_back" />
39+
</LinearLayout>
40+
41+
<LinearLayout
42+
android:layout_width="match_parent"
43+
android:layout_height="0dp"
44+
android:layout_weight="1"
45+
android:baselineAligned="false"
46+
android:orientation="horizontal"
47+
android:paddingStart="@dimen/spacing_lg"
48+
android:paddingTop="@dimen/chargepilot_bar_gap"
49+
android:paddingEnd="@dimen/spacing_lg"
50+
android:paddingBottom="@dimen/spacing_lg">
51+
52+
<ScrollView
53+
style="@style/Widget.EV.Readout"
54+
android:layout_width="0dp"
55+
android:layout_height="match_parent"
56+
android:layout_weight="2"
57+
android:padding="@dimen/spacing_md">
58+
59+
<LinearLayout
60+
android:layout_width="match_parent"
61+
android:layout_height="wrap_content"
62+
android:orientation="vertical">
63+
64+
<TextView
65+
style="@style/Text.EV.ReadoutLabel"
66+
android:text="@string/charge_history_analysis" />
67+
68+
<TextView
69+
android:id="@+id/analysisFacts"
70+
android:layout_width="match_parent"
71+
android:layout_height="wrap_content"
72+
android:layout_marginTop="@dimen/spacing_sm"
73+
android:fontFeatureSettings="tnum"
74+
android:textAppearance="@style/Text.EV.Body" />
75+
76+
<TextView
77+
style="@style/Text.EV.ReadoutLabel"
78+
android:layout_marginTop="@dimen/spacing_md"
79+
android:text="@string/charge_history_curve" />
80+
81+
<TextView
82+
android:id="@+id/curveFacts"
83+
android:layout_width="match_parent"
84+
android:layout_height="wrap_content"
85+
android:layout_marginTop="@dimen/spacing_sm"
86+
android:fontFeatureSettings="tnum"
87+
android:textAppearance="@style/Text.EV.Secondary" />
88+
</LinearLayout>
89+
</ScrollView>
90+
91+
<ScrollView
92+
style="@style/Widget.EV.Readout"
93+
android:layout_width="0dp"
94+
android:layout_height="match_parent"
95+
android:layout_marginStart="@dimen/spacing_md"
96+
android:layout_weight="3"
97+
android:padding="@dimen/spacing_md">
98+
99+
<LinearLayout
100+
android:layout_width="match_parent"
101+
android:layout_height="wrap_content"
102+
android:orientation="vertical">
103+
104+
<TextView
105+
style="@style/Text.EV.ReadoutLabel"
106+
android:text="@string/charge_history_list" />
107+
108+
<TextView
109+
android:id="@+id/chargeList"
110+
android:layout_width="match_parent"
111+
android:layout_height="wrap_content"
112+
android:layout_marginTop="@dimen/spacing_sm"
113+
android:fontFeatureSettings="tnum"
114+
android:lineSpacingMultiplier="1.15"
115+
android:textAppearance="@style/Text.EV.Body" />
116+
</LinearLayout>
117+
</ScrollView>
118+
</LinearLayout>
119+
</LinearLayout>

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,25 @@ Consommation %6$s %% par km</string>
470470
<string name="battery_health_exposure_km">%1$.0f km par cycle équivalent.</string>
471471
<string name="battery_health_last_charge">Dernière charge : +%1$.0f points en %2$.1f h — environ %3$.1f kWh à %4$.1f kW, d’après la tension × le courant du pack intégrés ici.</string>
472472
<string name="battery_health_last_charge_unwatched">Dernière charge : +%1$.0f points en %2$.1f h. Pas de valeur d’énergie : l’application n’échantillonnait pas pendant une partie de la charge.</string>
473-
<string name="battery_health_last_charge_none">Aucune charge mesurée pour l’instant. Une charge apparaît ici une fois que l’application en a suivi une du début à la fin, voiture à l’arrêt.</string>
473+
<string name="battery_health_last_charge_none">Aucune charge enregistrée pour l’instant. Une charge apparaît ici dès que l’application a vu la jauge monter entre deux de ses relevés, voiture à l’arrêt ou branchée.</string>
474+
<!-- CP-082. Historique des charges : mesuré uniquement, sans coût. -->
475+
<string name="charge_history_title">Charges</string>
476+
<string name="charge_history_open">Historique des charges</string>
477+
<string name="charge_history_analysis">Analyse</string>
478+
<string name="charge_history_curve">Courbe de charge — puissance mesurée du pack par niveau de charge</string>
479+
<string name="charge_history_list">Charges, la plus récente d’abord</string>
480+
<string name="charge_history_empty">Aucune charge enregistrée pour l’instant. Une charge apparaît dès que l’application a vu la jauge monter entre deux de ses relevés — y compris sur une nuit où l’écran était en veille.</string>
481+
<string name="charge_history_summary">%1$d charges, dont %2$d suivies du début à la fin. +%3$.0f points remis au total.</string>
482+
<string name="charge_history_typical">Charge médiane : +%1$.0f points à %2$.1f points par heure. La plus rapide : %3$.1f points par heure.</string>
483+
<string name="charge_history_energy">Charges suivies : %1$.1f kWh entrés dans le pack, moyenne %2$.1f kW, pic %3$.1f kW.</string>
484+
<string name="charge_history_verdicts">Signe du courant pack pendant une charge : %1$s. Compteurs kWh de la voiture : %2$s. Valeurs d’état de charge vues : %3$s.</string>
485+
<string name="charge_history_temperature">Température extérieure, pas celle du pack : la voiture ne publie pas la température du pack.</string>
486+
<string name="charge_history_band">%1$d–%2$d %% : %3$.1f kW sur %4$.1f h</string>
487+
<string name="charge_history_curve_empty">Aucune charge suivie pour l’instant. La courbe se remplit quand l’écran reste allumé pendant une charge (page Batterie ou tableau de bord ouverts, ou détection automatique active).</string>
488+
<string name="charge_history_row">%1$s → %2$s · %3$.0f → %4$.0f %% (+%5$.0f pt) · %6$.1f h · %7$.1f pt/h</string>
489+
<string name="charge_history_row_watched">≈ %1$.1f kWh entrés, moyenne %2$.1f kW, pic %3$.1f kW — tension × courant du pack intégrés ici.</string>
490+
<string name="charge_history_row_unwatched">Écran en veille pendant une partie de la charge : ni énergie ni puissance, et la durée est l’écart entre deux relevés.</string>
491+
<string name="charge_history_row_temperature">Extérieur de %1$.0f à %2$.0f °C.</string>
474492
<string name="battery_health_exposure_sessions">%1$d sessions de charge, la plus rapide à %2$.0f points par heure.</string>
475493
<string name="battery_health_exposure_unavailable">Rien d’enregistré pour l’instant. Cela se remplit à mesure que l’application lit la voiture.</string>
476494
<string name="battery_health_read_only">Cette application ne change rien sur la voiture : ni limite de charge, ni programmation, ni préchauffage. Elle mesure et elle dit ce qu’elle voit.</string>

0 commit comments

Comments
 (0)