Skip to content

Commit 7962c73

Browse files
malysclaude
andcommitted
feat(chargepilot): CP-081 eco voice that can be heard, advice in P
The coach asked the SAIC voice service whether it was bound in the same instant it started binding, so a line could fall to an Android engine the car does not have. EcoVoice now waits for the bind on its own worker thread, then falls back. Turning the voice on says a test line and writes the outcome under the switch; the bundle carries vehicle_voice_bound. When a trip is stored in P with advice and voice on, the car says what it cost against the driver's own average and what would have made it cheaper: sharp acceleration and braking time, Eco mode when the car was not in it, Snow mode in the cold, the fast-road and cabin findings. The trips overview gains a card of general advice over the last 20 trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 10a8d72 commit 7962c73

16 files changed

Lines changed: 912 additions & 56 deletions

‎CHANGELOG.md‎

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

77
### Fixed
88

9+
- **The eco voice can be heard, and checked.** The coach now waits for the vehicle voice service
10+
to bind before speaking instead of asking it in the same instant it starts binding, and speaks
11+
on its own worker thread. Turning the voice on says a test line at once; if neither the car's
12+
voice nor an Android engine answers, the settings screen says so under the switch, and the
13+
diagnostic report carries `vehicle_voice_bound`.
14+
15+
### Added
16+
17+
- **Eco advice when the trip ends in P.** With advice and voice on, the car says what the trip
18+
cost against the driver's own average and what would have made it cheaper: "You could have
19+
saved about 12 % (1.1 kWh) on this trip by accelerating and braking less sharply for 6 min or
20+
by driving in Eco mode." Hard braking now counts beside hard acceleration; Eco mode is named
21+
only when the car was not already in it, Snow mode only for a sharp drive at 3 °C or below.
22+
- **General eco advice beside the trip graphs.** The trips overview adds a card over the last 20
23+
trips: how many used more than the driver's own average and what the excess added up to, time
24+
spent accelerating and braking sharply, the fast-road saving at 10 km/h less, and the cabin's
25+
mean share.
26+
927
- **The eco coach speaks on the car.** Voice coaching went through the Android text-to-speech
1028
engine only, and the MG4 has none: it talks through the SAIC vehicle voice service. The coach
1129
now uses that service first, queued behind any announcement in progress, and keeps the Android

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class ChargePilotApp : Application() {
1212
// Unstable arms the validation probes here so a drive is recorded without anyone
1313
// opening a screen first; stable does nothing, and contains none of this.
1414
EvidenceCaptureHook.startProbes(this)
15+
// The vehicle voice binds asynchronously; asking now means the first coach line, or the
16+
// settings test, finds it bound. Idempotent, and a car without it just falls back.
17+
if (EcoCoach.voiceEnabled(this)) EcoVoice.connect(this)
1518
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) seedSettings()
1619
}
1720

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import android.widget.Toast
1414
import androidx.appcompat.app.AlertDialog
1515
import com.evsuite.chargepilot.databinding.ActivityDiagnosticsBinding
1616
import com.evsuite.chargepilot.update.UpdateHook
17+
import com.evsuite.hardware.saic.SaicTts
1718
import com.evsuite.hardware.AppLogger
1819
import com.evsuite.hardware.CarPropertyEvidence
1920
import com.evsuite.hardware.EVHardware
@@ -293,6 +294,9 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
293294
appendLine(getString(R.string.diagnostics_eco_driving))
294295
appendLine("advice_enabled=${EcoCoach.adviceEnabled(this@DiagnosticsActivity)}")
295296
appendLine("voice_enabled=${EcoCoach.voiceEnabled(this@DiagnosticsActivity)}")
297+
// CP-081: whether the SAIC voice service is bound. False with the voice on means
298+
// every line fell back to an Android engine the car does not have.
299+
appendLine("vehicle_voice_bound=${SaicTts.isAvailable}")
296300
DashboardFrame.eco.describe().forEach(::appendLine)
297301
appendLine()
298302
// CP-075. The same verdict the trip screen shows for the newest drive, so a bundle

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

Lines changed: 3 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
package com.evsuite.chargepilot
22

33
import android.content.Context
4-
import android.media.AudioAttributes
5-
import android.speech.tts.TextToSpeech
64
import com.evsuite.hardware.FirmwareInfo
7-
import com.evsuite.hardware.saic.SaicTts
85
import com.evsuite.hardware.telemetry.EcoAdvice
96
import com.evsuite.hardware.telemetry.EcoDrivingMonitor
107
import com.evsuite.hardware.telemetry.EcoLever
@@ -36,6 +33,8 @@ import kotlin.math.roundToInt
3633
* switches off in a week. It speaks when the advice becomes different advice, at most once per
3734
* [SPEAK_COOLDOWN_MS], and never while the car is stopped — a stationary car needs no cruising
3835
* speed and a passenger reading the screen is not being talked over.
36+
* The one spoken line a stopped car gets is the end-of-trip review, said once when the trip closes
37+
* in P ([EcoTripAdvice]); the speaking itself is [EcoVoice], the car's own voice first.
3938
*/
4039
class EcoCoach(context: Context) {
4140

@@ -48,8 +47,6 @@ class EcoCoach(context: Context) {
4847
private var lastComputedAtMs = 0L
4948
private var verdict = EcoVerdict(Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED))
5049

51-
private var speaker: TextToSpeech? = null
52-
private var speakerReady = false
5350
private var spokenLine: String? = null
5451
private var lastSpokeAtMs = 0L
5552

@@ -131,53 +128,9 @@ class EcoCoach(context: Context) {
131128
if (line == null || !voiceEnabled(app) || !adviceEnabled(app)) return
132129
if (speedKmh == null || speedKmh <= MOVING_KMH) return
133130
if (line == spokenLine || nowMs - lastSpokeAtMs < SPEAK_COOLDOWN_MS) return
134-
// The car's own voice first, as EVTasker's Speaker does: the MG4 has no Android TTS
135-
// engine, so the platform path below only ever speaks on a bench emulator. Queued
136-
// rather than interrupting — a navigation instruction outranks the coach.
137-
SaicTts.connect(app)
138-
if (SaicTts.isAvailable && SaicTts.speak(line, interrupt = false, tag = app.packageName)) {
139-
spokenLine = line
140-
lastSpokeAtMs = nowMs
141-
return
142-
}
143-
val engine = speaker ?: TextToSpeech(app) { status ->
144-
speakerReady = status == TextToSpeech.SUCCESS && speaker?.let(::hasVoice) == true
145-
}.also {
146-
// The head unit ducks the radio for an assistant rather than stopping it, and a
147-
// coach is not a media stream competing with the music the driver chose.
148-
it.setAudioAttributes(
149-
AudioAttributes.Builder()
150-
.setUsage(AudioAttributes.USAGE_ASSISTANT)
151-
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
152-
.build()
153-
)
154-
speaker = it
155-
}
156-
if (!speakerReady) return
157131
spokenLine = line
158132
lastSpokeAtMs = nowMs
159-
engine.speak(line, TextToSpeech.QUEUE_FLUSH, null, UTTERANCE_ID)
160-
}
161-
162-
/**
163-
* Gives the engine the language the line was written in, and says whether it has that voice.
164-
*
165-
* The locale comes from the resources the line itself was resolved against, so the two can
166-
* never disagree. Where the engine has no voice for it the coach stays silent: a French
167-
* sentence read with English phonemes is not a degraded reading, it is an unparseable one,
168-
* and it would arrive at 110 km/h with no way to ask for it again.
169-
*/
170-
private fun hasVoice(engine: TextToSpeech): Boolean {
171-
val result = engine.setLanguage(app.resources.configuration.locales[0])
172-
return result != TextToSpeech.LANG_MISSING_DATA &&
173-
result != TextToSpeech.LANG_NOT_SUPPORTED
174-
}
175-
176-
/** Releases the engine with the screen. Nothing of this app keeps talking in the background. */
177-
fun close() {
178-
speaker?.shutdown()
179-
speaker = null
180-
speakerReady = false
133+
EcoVoice.say(app, line)
181134
}
182135

183136
/** Consequence with a number in it. No imperative is written here, in any language. */
@@ -206,7 +159,6 @@ class EcoCoach(context: Context) {
206159
/** Two minutes between two spoken lines, however often the advice changes. */
207160
private const val SPEAK_COOLDOWN_MS = 120_000L
208161

209-
private const val UTTERANCE_ID = "chargepilot-eco"
210162
private const val DASH = "—"
211163

212164
private const val PREFERENCES = "eco_coach"

0 commit comments

Comments
 (0)