Skip to content

Commit 07a84e3

Browse files
malysclaude
andcommitted
feat(battery): CP-087 daily digest, advice history and parked notifications
The service stores a daily battery digest at the first sample of each local day, in both flavors, and turns what moved past its own band into advice. Every advice, the trip-end line included, is kept word for word in AdviceJournal, shown newest first on a new Advice history screen from the Battery page, and added to the diagnostic bundle. With the car parked, a Battery advice notification carries it, at most one per kind a day, behind a Settings switch. No new permission: the head unit's API 28 needs none; on API 33+ nothing is posted. EVHardware to 1c95636 (BatteryDigest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ff3e978 commit 07a84e3

17 files changed

Lines changed: 548 additions & 5 deletions

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **A battery companion that speaks up once a day (CP-087).** At the first sample of each local
10+
day the service recomputes the battery analyses in both flavors and stores the day's digest.
11+
What moved past its own band since the day before — a first health estimate, a band that
12+
narrowed, health that moved, a calibration verdict that changed — becomes advice. Every
13+
advice, the trip-end line included, is kept word for word in an advice history (Battery page,
14+
"Advice history", newest first) and in the diagnostic bundle (`[battery_advice]`). With the car
15+
parked, a "Battery advice" notification carries it, at most one per kind a day; a Settings
16+
switch turns it off. No new permission: the head unit's API 28 needs none, and on API 33+ the
17+
advice stays in the history only.
918
- **Open windows, measured and counted (CP-086).** A Windows tile in the Details climate grid
1019
shows the widest opening in percent. The trip review adds a Windows finding when the window-open
1120
residual is distinguishable and above the cabin's minimum share: its kWh, its share, and the

‎app/src/main/AndroidManifest.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@
133133
android:exported="false"
134134
android:screenOrientation="landscape" />
135135

136+
<activity
137+
android:name=".AdviceHistoryActivity"
138+
android:exported="false"
139+
android:screenOrientation="landscape" />
140+
136141
<activity
137142
android:name=".ChargeStopActivity"
138143
android:exported="false"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.os.Bundle
4+
import androidx.appcompat.app.AppCompatActivity
5+
import com.evsuite.chargepilot.databinding.ActivityAdviceHistoryBinding
6+
import com.evsuite.chargepilot.databinding.ItemAdviceRowBinding
7+
import java.text.DateFormat
8+
import java.util.Date
9+
import java.util.concurrent.Executors
10+
11+
/**
12+
* Every advice the app gave, newest first, word for word (CP-087).
13+
*
14+
* A notification is gone once dismissed; this is where it can be read again. Rows are the
15+
* [AdviceJournal] as stored — nothing is recomputed, so a line reads as it did when it was said.
16+
*/
17+
class AdviceHistoryActivity : AppCompatActivity() {
18+
19+
private lateinit var binding: ActivityAdviceHistoryBinding
20+
private val disk = Executors.newSingleThreadExecutor { runnable ->
21+
Thread(runnable, "chargepilot-advice-history")
22+
}
23+
24+
override fun onCreate(savedInstanceState: Bundle?) {
25+
super.onCreate(savedInstanceState)
26+
binding = ActivityAdviceHistoryBinding.inflate(layoutInflater)
27+
setContentView(binding.root)
28+
binding.backAction.setOnClickListener { finish() }
29+
disk.execute {
30+
val entries = AdviceJournal.of(filesDir).read()
31+
runOnUiThread {
32+
if (!isFinishing && !isDestroyed) render(entries)
33+
}
34+
}
35+
}
36+
37+
override fun onDestroy() {
38+
disk.shutdownNow()
39+
super.onDestroy()
40+
}
41+
42+
private fun render(entries: List<AdviceEntry>) {
43+
binding.adviceRows.removeAllViews()
44+
binding.adviceEmpty.visibility = if (entries.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
45+
val format = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
46+
entries.asReversed().forEach {
47+
val row = ItemAdviceRowBinding.inflate(layoutInflater, binding.adviceRows, true)
48+
row.rowDate.text = format.format(Date(it.atMs))
49+
row.rowText.text = it.text
50+
}
51+
}
52+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.google.gson.Gson
4+
import java.io.File
5+
import java.io.FileOutputStream
6+
7+
/** Where an advice came from. The history screen and the notification cap read it. */
8+
enum class AdviceKind { TRIP_END, BATTERY, PARKED, CHARGING, DRIVING }
9+
10+
/**
11+
* One advice as the driver was given it.
12+
*
13+
* The text is kept as it was said, in the language it was said in: it is a record of what the app
14+
* told the driver, and a history that rewrote itself would not be one.
15+
*/
16+
data class AdviceEntry(
17+
val atMs: Long,
18+
val kind: AdviceKind,
19+
val text: String,
20+
val notified: Boolean = false,
21+
)
22+
23+
/**
24+
* Every advice the app gives, newest last, bounded (CP-087).
25+
*
26+
* The same atomic write as the trip and ledger stores. An unreadable file is set aside rather
27+
* than overwritten, so a bad write never loses the history that was there.
28+
*/
29+
class AdviceJournal(
30+
private val target: File,
31+
private val maxEntries: Int = MAX_ENTRIES,
32+
private val gson: Gson = Gson(),
33+
) {
34+
@Synchronized
35+
fun read(): List<AdviceEntry> {
36+
if (!target.exists()) return emptyList()
37+
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
38+
val envelope = runCatching { gson.fromJson(text, Envelope::class.java) }.getOrNull()
39+
if (envelope == null || envelope.schemaVersion != SCHEMA_VERSION) {
40+
target.renameTo(File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}"))
41+
return emptyList()
42+
}
43+
@Suppress("SENSELESS_COMPARISON")
44+
return envelope.entries.orEmpty()
45+
.filter { it != null && it.kind != null && it.text != null }
46+
.sortedBy { it.atMs }
47+
}
48+
49+
@Synchronized
50+
fun append(entry: AdviceEntry): Boolean = write((read() + entry).takeLast(maxEntries))
51+
52+
/** Marks the given entries as notified; the rest stay as they are. */
53+
@Synchronized
54+
fun markNotified(entries: Collection<AdviceEntry>): Boolean {
55+
val marked = entries.toSet()
56+
return write(read().map { if (it in marked) it.copy(notified = true) else it })
57+
}
58+
59+
private fun write(entries: List<AdviceEntry>): Boolean {
60+
val bytes = gson.toJson(Envelope(SCHEMA_VERSION, entries)).toByteArray(Charsets.UTF_8)
61+
target.parentFile?.mkdirs()
62+
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
63+
return try {
64+
FileOutputStream(temp).use { output ->
65+
output.write(bytes)
66+
output.fd.sync()
67+
}
68+
if (temp.renameTo(target)) true else {
69+
temp.delete()
70+
false
71+
}
72+
} catch (_: Exception) {
73+
temp.delete()
74+
false
75+
}
76+
}
77+
78+
private data class Envelope(val schemaVersion: Int = 0, val entries: List<AdviceEntry>? = null)
79+
80+
companion object {
81+
const val SCHEMA_VERSION = 1
82+
const val MAX_ENTRIES = 500
83+
const val FILE_NAME = "advice-journal.json"
84+
85+
fun of(filesDir: File) = AdviceJournal(File(filesDir, FILE_NAME))
86+
}
87+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.app.NotificationChannel
6+
import android.app.NotificationManager
7+
import android.app.PendingIntent
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.content.pm.PackageManager
11+
import android.os.Build
12+
import androidx.core.app.NotificationCompat
13+
import androidx.core.app.NotificationManagerCompat
14+
import androidx.core.content.ContextCompat
15+
import com.evsuite.hardware.AppLogger
16+
import com.evsuite.hardware.telemetry.BatteryDigest
17+
import com.evsuite.hardware.telemetry.BatteryDigestStore
18+
import com.evsuite.hardware.telemetry.CalibrationVerdict
19+
import com.evsuite.hardware.telemetry.DigestChange
20+
import com.evsuite.hardware.telemetry.EnergySnapshot
21+
import java.io.File
22+
import java.time.Instant
23+
import java.time.ZoneId
24+
25+
/**
26+
* The battery companion's daily round and its one way of speaking up unasked (CP-087).
27+
*
28+
* **Once a day.** At the first sample of a calendar day the four ledger analyses are recomputed,
29+
* in both flavors, and the day is written to [BatteryDigestStore]. What moved past its own band
30+
* since the day before becomes advice. A process that restarts during the day rewrites the day
31+
* and says nothing again.
32+
*
33+
* **Parked only.** Advice goes to the [AdviceJournal] at once; the notification waits for a car
34+
* that reads stopped, fails closed on an unreadable speed, and posts at most one per kind a day.
35+
* A moving car gets nothing here: driving advice stays with the voice (CP-083).
36+
*
37+
* Runs on the sampler thread, which owns the ledger it reads.
38+
*/
39+
internal class BatteryAdvisor(
40+
private val context: Context,
41+
/** The app's own language: a service keeps the head unit's otherwise (CP-077). */
42+
private val text: () -> Context,
43+
private val zone: ZoneId = ZoneId.systemDefault(),
44+
) {
45+
private val filesDir: File = context.filesDir
46+
private val journal = AdviceJournal.of(filesDir)
47+
private val digests = BatteryDigestStore(File(filesDir, BatteryDigestStore.FILE_NAME))
48+
private var digestDay: String? = null
49+
private var pending = true
50+
51+
fun onSample(value: EnergySnapshot) {
52+
val day = Instant.ofEpochMilli(value.timestampMs).atZone(zone).toLocalDate().toString()
53+
if (day != digestDay) {
54+
digestDay = day
55+
runCatching { digest(day, value.timestampMs) }
56+
.onFailure { AppLogger.w(TAG, "battery digest failed: ${it.message}") }
57+
}
58+
if (!pending) return
59+
val gate = ParkedDeletionPolicy.gate(value.speedKmh, value.timestampMs, System.currentTimeMillis())
60+
if (gate != ParkedDeletionGate.PARKED) return
61+
runCatching { flush(value.timestampMs) }
62+
.onFailure { AppLogger.w(TAG, "advice notification failed: ${it.message}") }
63+
}
64+
65+
/** Anything the app says — the trip-end line included — is kept, then notified when parked. */
66+
fun record(kind: AdviceKind, line: String, atMs: Long) {
67+
if (journal.append(AdviceEntry(atMs, kind, line))) pending = true
68+
}
69+
70+
private fun digest(day: String, nowMs: Long) {
71+
val settings = VehicleSettings.read(context)
72+
val learned = LocalBatteryLearning.refresh(filesDir, settings.usableCapacityKwhWhenNew, nowMs)
73+
val today = BatteryDigest.of(day, learned.health, learned.drift, learned.exposure, learned.charge)
74+
val alreadyToday = digests.read().lastOrNull()?.day == day
75+
val previous = digests.upsert(today)
76+
if (alreadyToday) return
77+
BatteryDigest.changes(previous, today).forEach { record(AdviceKind.BATTERY, describe(it), nowMs) }
78+
}
79+
80+
private fun describe(change: DigestChange): String {
81+
val res = text()
82+
return when (change) {
83+
is DigestChange.HealthMoved -> res.getString(
84+
R.string.advice_health_moved, change.toPercent, change.bandPercent, change.fromPercent,
85+
)
86+
is DigestChange.HealthBandNarrowed -> {
87+
val from = change.fromBandPercent
88+
if (from == null) {
89+
res.getString(R.string.advice_health_first, change.healthPercent, change.toBandPercent)
90+
} else {
91+
res.getString(
92+
R.string.advice_health_narrowed, change.healthPercent, change.toBandPercent, from,
93+
)
94+
}
95+
}
96+
is DigestChange.CalibrationChanged -> {
97+
val state = res.getString(
98+
when (change.to) {
99+
CalibrationVerdict.SETTLED -> R.string.battery_health_calibration_state_settled
100+
CalibrationVerdict.WATCH -> R.string.battery_health_calibration_state_watch
101+
CalibrationVerdict.RE_ANCHOR_SUGGESTED -> R.string.battery_health_calibration_state_re_anchor
102+
},
103+
)
104+
listOfNotNull(
105+
res.getString(R.string.advice_calibration, state),
106+
change.daysSinceFullCharge?.let {
107+
res.getString(R.string.battery_health_calibration_last_full, it)
108+
},
109+
if (change.to == CalibrationVerdict.RE_ANCHOR_SUGGESTED) {
110+
res.getString(R.string.battery_health_calibration_re_anchor)
111+
} else null,
112+
).joinToString(" ")
113+
}
114+
}
115+
}
116+
117+
private fun flush(nowMs: Long) {
118+
pending = false
119+
if (!notificationsEnabled(context)) return
120+
val manager = NotificationManagerCompat.from(context)
121+
if (!manager.areNotificationsEnabled()) return
122+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
123+
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
124+
PackageManager.PERMISSION_GRANTED
125+
) return
126+
val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate()
127+
val entries = journal.read()
128+
val ofToday = entries.filter {
129+
Instant.ofEpochMilli(it.atMs).atZone(zone).toLocalDate() == today
130+
}
131+
val waiting = ofToday.filter { !it.notified }
132+
if (waiting.isEmpty()) return
133+
val spent = ofToday.filter { it.notified }.map { it.kind }.toSet()
134+
val posted = waiting.groupBy { it.kind }
135+
.filterKeys { it !in spent }
136+
.map { (kind, list) -> post(manager, kind, list.last()); list.last() }
137+
// What the cap held back is still in the history; it is not queued for tomorrow.
138+
journal.markNotified(waiting.filter { entry -> posted.any { it.kind == entry.kind } } + posted)
139+
}
140+
141+
// The head unit runs API 28, where no permission gates a notification. POST_NOTIFICATIONS is
142+
// deliberately not declared (see the manifest); on API 33+ flush() finds it missing and posts
143+
// nothing, and the advice stays in the history.
144+
@SuppressLint("MissingPermission", "NotificationPermission")
145+
private fun post(manager: NotificationManagerCompat, kind: AdviceKind, entry: AdviceEntry) {
146+
val res = text()
147+
createChannel(res)
148+
val intent = Intent(context, AdviceHistoryActivity::class.java)
149+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
150+
val tap = PendingIntent.getActivity(
151+
context, kind.ordinal, intent,
152+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
153+
)
154+
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
155+
.setSmallIcon(R.drawable.ic_launcher_monochrome)
156+
.setContentTitle(res.getString(R.string.advice_notification_title))
157+
.setContentText(entry.text)
158+
.setStyle(NotificationCompat.BigTextStyle().bigText(entry.text))
159+
.setContentIntent(tap)
160+
.setAutoCancel(true)
161+
.build()
162+
manager.notify(NOTIFICATION_BASE_ID + kind.ordinal, notification)
163+
}
164+
165+
private fun createChannel(res: Context) {
166+
val manager = context.getSystemService(NotificationManager::class.java) ?: return
167+
manager.createNotificationChannel(
168+
NotificationChannel(
169+
CHANNEL_ID,
170+
res.getString(R.string.advice_channel),
171+
NotificationManager.IMPORTANCE_DEFAULT,
172+
).apply { description = res.getString(R.string.advice_channel_description) },
173+
)
174+
}
175+
176+
companion object {
177+
private const val TAG = "EVChargePilot"
178+
private const val CHANNEL_ID = "battery_advice"
179+
private const val NOTIFICATION_BASE_ID = 100
180+
private const val PREFERENCES = "battery_advice"
181+
private const val PREF_NOTIFY = "notifications_enabled"
182+
183+
fun notificationsEnabled(context: Context): Boolean = context.applicationContext
184+
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).getBoolean(PREF_NOTIFY, true)
185+
186+
fun storeNotificationsEnabled(context: Context, enabled: Boolean) = context.applicationContext
187+
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit()
188+
.putBoolean(PREF_NOTIFY, enabled).apply()
189+
}
190+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
102102
binding.chargeHistoryAction.setOnClickListener {
103103
startActivity(Intent(this, ChargeHistoryActivity::class.java))
104104
}
105+
binding.adviceHistoryAction.setOnClickListener {
106+
startActivity(Intent(this, AdviceHistoryActivity::class.java))
107+
}
105108
load()
106109
}
107110

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.evsuite.hardware.EVHardware
2121
import com.evsuite.hardware.FirmwareInfo
2222
import com.evsuite.hardware.diag.CrashLogger
2323
import com.evsuite.hardware.telemetry.BatteryExposure
24+
import com.evsuite.hardware.telemetry.BatteryDigestStore
2425
import com.evsuite.hardware.telemetry.BatteryLedgerStore
2526
import com.evsuite.hardware.telemetry.ChargeEnergyAnalyzer
2627
import com.evsuite.hardware.telemetry.EnergySnapshot
@@ -305,6 +306,15 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
305306
appendLine(getString(R.string.diagnostics_eco_trip_review))
306307
latestTripReviewLines().forEach(::appendLine)
307308
appendLine()
309+
// CP-087. The last week of daily battery digests and the newest advice as it was
310+
// given, so a bundle shows what the companion concluded and what it told the driver.
311+
appendLine("[battery_advice]")
312+
appendLine("notifications_enabled=${BatteryAdvisor.notificationsEnabled(this@DiagnosticsActivity)}")
313+
BatteryDigestStore(File(filesDir, BatteryDigestStore.FILE_NAME)).read().takeLast(7)
314+
.forEach { appendLine("digest=$it") }
315+
AdviceJournal.of(filesDir).read().takeLast(20)
316+
.forEach { appendLine("advice=${it.atMs} ${it.kind} notified=${it.notified} ${it.text}") }
317+
appendLine()
308318
appendLine("[property_probe]")
309319
appendLine(getString(R.string.diagnostics_properties))
310320
appendLine(getString(R.string.diagnostics_properties_hint))

0 commit comments

Comments
 (0)