Skip to content

Commit e0b8810

Browse files
malysclaude
andcommitted
feat: speak through the vehicle's own voice service
The MG4 has no Android TTS engine and does not need one: it announces tyre faults and navigation through a SAIC vendor service. Probing `android.speech.tts` and finding nothing was the right answer to the wrong question — which is why the speak action reported "no speech engine" on a car that talks. component com.saicmotor.voicetts / .TtsService (no intent filter, bound explicitly — the way the car's own voice app reaches it) descriptor com.saicmotor.voicetts.ITtsService tx 1 promptCommonWords(text, interrupt, tag) tx 3 stopPrompt() Source: apks/saicvoiceservice_overseas_eh32 — VoiceAnnounceModel for the component name and the call, ITtsService for the codes. SaicService gains a by-component constructor for services that publish no action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e3b9bb3 commit e0b8810

2 files changed

Lines changed: 66 additions & 6 deletions

File tree

‎lib/src/main/java/com/mg4/hardware/saic/SaicIpc.kt‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,19 @@ import com.mg4.hardware.AppLogger
2121
* service is up returns null rather than blocking: the rule engine has a cycle to finish, and
2222
* a vendor service that is slow to come up must not hold it.
2323
*/
24-
class SaicService(
24+
class SaicService private constructor(
2525
private val packageName: String,
26-
private val action: String,
26+
private val action: String?,
27+
private val className: String?,
2728
private val tag: String,
2829
) {
2930

31+
/** Most vendor services publish an action; this is the usual way in. */
32+
constructor(packageName: String, action: String, tag: String) :
33+
this(packageName, action, null, tag)
34+
35+
36+
3037
@Volatile
3138
private var binder: IBinder? = null
3239

@@ -45,13 +52,17 @@ class SaicService(
4552
fun connect(context: Context) {
4653
if (isReady || connecting) return
4754
connecting = true
48-
val intent = Intent(action).setPackage(packageName)
55+
val intent = if (className != null) {
56+
Intent().setComponent(ComponentName(packageName, className))
57+
} else {
58+
Intent(action).setPackage(packageName)
59+
}
4960
val bound = runCatching {
5061
context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
5162
}.getOrDefault(false)
5263
if (!bound) {
5364
connecting = false
54-
AppLogger.w(TAG, "$tag: bindService($packageName/$action) refused")
65+
AppLogger.w(TAG, "$tag: bindService($packageName/${className ?: action}) refused")
5566
}
5667
}
5768

@@ -68,8 +79,15 @@ class SaicService(
6879
}
6980
}
7081

71-
private companion object {
72-
const val TAG = "MG4_SAIC"
82+
companion object {
83+
private const val TAG = "MG4_SAIC"
84+
85+
/**
86+
* For a service with no intent filter, reached by explicit component — how the car's
87+
* own apps bind the TTS service, and the only way it answers.
88+
*/
89+
fun byComponent(packageName: String, className: String, tag: String) =
90+
SaicService(packageName, null, className, tag)
7391
}
7492
}
7593

‎lib/src/main/java/com/mg4/hardware/saic/SaicMedia.kt‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,45 @@ object SaicPhone {
8787
SaicAidl.callString(service.binder(), DESCRIPTOR, TX_GET_CONN_DEVICE_NAME)
8888
?.takeIf { it.isNotBlank() }
8989
}
90+
91+
/**
92+
* The vehicle's own voice — the one that says "left front tyre abnormality".
93+
*
94+
* This is what makes the "speak" action work on a head unit with no Android TTS engine
95+
* installed, which is the normal case: the car talks, but through a vendor service rather
96+
* than through `android.speech.tts`. Probing for a platform engine and finding none was
97+
* therefore the right answer to the wrong question.
98+
*
99+
* Bound by component name, not by action — the service declares no intent filter, and the
100+
* car's own voice app reaches it exactly this way.
101+
*
102+
* Source: `apks/saicvoiceservice_overseas_eh32` — `VoiceAnnounceModel` (component name and
103+
* the call it makes) and `ITtsService` (descriptor, transaction codes).
104+
*/
105+
object SaicTts {
106+
107+
private const val PACKAGE = "com.saicmotor.voicetts"
108+
private const val CLASS = "com.saicmotor.voicetts.TtsService"
109+
private const val DESCRIPTOR = "com.saicmotor.voicetts.ITtsService"
110+
111+
private const val TX_PROMPT = 1
112+
private const val TX_STOP = 3
113+
114+
private val service = SaicService.byComponent(PACKAGE, CLASS, "voicetts")
115+
116+
val isAvailable: Boolean get() = service.isReady
117+
118+
fun connect(context: Context) = service.connect(context)
119+
120+
/**
121+
* Speaks [text].
122+
*
123+
* [interrupt] true stops whatever the car is currently announcing — false queues behind
124+
* it, which is what a rule message should do: a tyre warning outranks "profile applied".
125+
* The tag is the caller's package, as the vendor apps pass their own.
126+
*/
127+
fun speak(text: String, interrupt: Boolean = false, tag: String = "com.mg4.tasker"): Boolean =
128+
SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_PROMPT, text, interrupt, tag)
129+
130+
fun stop(): Boolean = SaicAidl.callVoid(service.binder(), DESCRIPTOR, TX_STOP)
131+
}

0 commit comments

Comments
 (0)