Skip to content

Commit 185a0fe

Browse files
malysclaude
andcommitted
feat(diagnostic): read-only probes for radio, OEM screens, vendor surface and power cycle
The 2026-10-13 triage of CR-020, CR-030, CR-045/046 and RI-004 left each ticket waiting on one fact only the car can give, and the owner installs stable builds only. Every probe here reads; none writes to the vehicle. - CR-020: ALLOWED on a radio action only says the binder call returned. The radio actions now read SaicRadio.currentStation() before the call and 1.5 s after it, on the rule's worker thread, and the history detail carries both. Diagnostic row RADIO_STATE does the same read on demand. - CR-030: Diagnostic row OEM_SCREENS lists the exported activities of the installed com.saicmotor.* packages, capped at 150 lines. Nothing launched. - CR-045/046: the capture tool sat in EVProfile unstable only. The stable Diagnostic tab now calls EVHardware's VendorSurfaceProbe.capture() (getters only, setters refused by VendorSurfaceRules) through Capture before / Capture after, and Export carries VendorSurfaceRules' diff. - RI-004: the vehicle service stores the last ignition-off and a 30 s heartbeat on its existing sampling handler (no wakelock, no alarm, apply() into app-private prefs). A heartbeat gap closes the previous cycle; row POWER_CYCLE prints its off, last heartbeat and the gap, which is the head unit's post-off run window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fed6ba7 commit 185a0fe

20 files changed

Lines changed: 499 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **Radio actions record the tuner before and after (CR-020).** `PLAY_RADIO`, `PAUSE_RADIO`,
12+
`TUNE_RADIO`, `RADIO_PLAY_PAUSE` and the next/previous station actions read the tuner just
13+
before the call and 1.5 s after it, and the history detail says both, e.g.
14+
`before=FM 101500 kHz playing after=FM 101500 kHz paused`. Read only; nothing extra is sent.
15+
- **Diagnostic: `RADIO_STATE` row.** The same tuner read on demand: band, frequency, playing.
16+
- **Diagnostic: `OEM_SCREENS` row (CR-030).** The exported activities of the installed
17+
`com.saicmotor.*` apps, capped at 150 lines with the rest counted. Nothing is launched.
18+
- **Diagnostic: *Capture before* / *Capture after* (CR-045, CR-046).** Reads every vendor value
19+
EVHardware can read, getters only. Capture, change one setting on the car's own screen at
20+
standstill, wait 2 s, capture again: Export adds the list of values that changed.
21+
- **Diagnostic: `POWER_CYCLE` row (RI-004).** The vehicle service records the time of each
22+
ignition-off and a heartbeat every 30 s in app-private storage. The row prints the previous
23+
cycle's ignition-off, its last heartbeat and the gap between them, then this boot: how long
24+
the head unit kept running after the car was switched off, to 30 s.
25+
926
## [2.15.1] - 2026-10-11
1027

1128
### Changed

‎app/src/main/java/com/evsuite/tasker/debug/DebugReport.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ object DebugReport {
8585
report.actions.forEach { appendLine(entryRow(it)) }
8686
appendLine()
8787

88+
SurfaceCaptures.report()?.let {
89+
section("Vendor surface diff (CR-045)")
90+
appendLine(it)
91+
appendLine()
92+
}
93+
8894
section("Rules")
8995
// The export format, not a bespoke dump: the rules can be pasted straight back into
9096
// an import to reproduce the problem on another car — minus what [redacted] takes out.
@@ -153,6 +159,7 @@ object DebugReport {
153159
fenced("Actions — ${report.blockedActions} blocked of ${report.actions.size}") {
154160
report.actions.forEach { appendLine(entryRow(it)) }
155161
}
162+
SurfaceCaptures.report()?.let { diff -> fenced("Vendor surface diff (CR-045)") { appendLine(diff) } }
156163
fenced("Rules", language = "json") {
157164
appendLine(RuleStore(context).getAll().let { rules ->
158165
if (rules.isEmpty()) "(none)" else RuleTransfer.encode(redacted(rules))

‎app/src/main/java/com/evsuite/tasker/debug/DiagnosticProbe.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.evsuite.hardware.saic.SaicVehicleControl
1717
import com.evsuite.tasker.bridge.BridgeContract
1818
import com.evsuite.tasker.service.TaskerVehicleService
1919
import com.evsuite.tasker.store.AppState
20+
import com.evsuite.tasker.store.PowerCycleStore
2021
import com.evsuite.tasker.store.SupportStore
2122
import com.evsuite.tasker.util.BtDevices
2223
import com.evsuite.tasker.util.CarLocation
@@ -27,6 +28,7 @@ import com.evsuite.tasker.vehicle.BtTracker
2728
import com.evsuite.tasker.vehicle.DeferredWrites
2829
import com.evsuite.tasker.vehicle.VendorServices
2930
import com.evsuite.tasker.vehicle.ProfileBridge
31+
import com.evsuite.tasker.vehicle.RadioState
3032
import com.evsuite.tasker.vehicle.VehicleReader
3133

3234
/**
@@ -117,6 +119,12 @@ object DiagnosticProbe {
117119
* why `PASSENGER_TEMP` and `SET_PASSENGER_TEMP` are hidden where it shows.
118120
*/
119121
CLIMATE_ZONES,
122+
/** The tuner as `SaicRadio.currentStation()` answers it: band, kHz, playing (CR-020). */
123+
RADIO_STATE,
124+
/** Exported activities of the `com.saicmotor.*` apps, bounded; nothing launched (CR-030). */
125+
OEM_SCREENS,
126+
/** The previous ignition-off, last heartbeat and this boot: the post-off run window (RI-004). */
127+
POWER_CYCLE,
120128
}
121129

122130
data class Report(
@@ -324,6 +332,13 @@ object DiagnosticProbe {
324332
detail = "driver=${SaicClimate.driverTemp() ?: "?"} " +
325333
"passenger=${SaicClimate.passengerTempState() ?: "?"} (raw)"
326334
),
335+
SaicRadio.currentStation().let {
336+
EnvCheck(Env.RADIO_STATE, ok = it != null, detail = RadioState.describe(it))
337+
},
338+
OemScreens.read(context).let {
339+
EnvCheck(Env.OEM_SCREENS, ok = it.isNotEmpty(), detail = OemScreens.format(it))
340+
},
341+
EnvCheck(Env.POWER_CYCLE, ok = true, detail = PowerCycleStore.describe(context)),
327342
)
328343
}
329344

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.evsuite.tasker.debug
2+
3+
import android.content.Context
4+
import android.content.pm.PackageManager
5+
6+
/**
7+
* The exported activities of the car's own `com.saicmotor.*` apps (CR-030): the screens
8+
* another app could open. Read from the package manager only — nothing is launched.
9+
*/
10+
object OemScreens {
11+
12+
private const val PREFIX = "com.saicmotor."
13+
14+
/** Bounds the Environment row; the report says when it was reached. */
15+
const val CAP = 150
16+
17+
/** Package → exported activity names, or the read error for that package. */
18+
fun read(context: Context): Map<String, List<String>> {
19+
val pm = context.packageManager
20+
val installed = try {
21+
pm.getInstalledPackages(0)
22+
} catch (e: Exception) {
23+
return mapOf("getInstalledPackages" to listOf("ERR ${e.javaClass.simpleName}"))
24+
}
25+
return installed
26+
.map { it.packageName }
27+
.filter { it.startsWith(PREFIX) }
28+
.sorted()
29+
.associateWith { pkg ->
30+
try {
31+
pm.getPackageInfo(pkg, PackageManager.GET_ACTIVITIES).activities
32+
?.filter { it.exported }?.map { it.name }.orEmpty()
33+
} catch (e: Exception) {
34+
listOf("ERR ${e.javaClass.simpleName}")
35+
}
36+
}
37+
}
38+
39+
/**
40+
* One line per activity, `package/.Activity` when the class sits under its package. Stops
41+
* at [cap] lines and names how many were left out.
42+
*/
43+
fun format(screens: Map<String, List<String>>, cap: Int = CAP): String {
44+
val lines = screens.flatMap { (pkg, names) ->
45+
if (names.isEmpty()) listOf("$pkg: no exported activity")
46+
else names.map { "$pkg/${if (it.startsWith("$pkg.")) it.removePrefix(pkg) else it}" }
47+
}
48+
if (lines.isEmpty()) return "no $PREFIX* package"
49+
if (lines.size <= cap) return lines.joinToString("\n")
50+
return (lines.take(cap) + "… ${lines.size - cap} more not listed (cap $cap)").joinToString("\n")
51+
}
52+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.evsuite.tasker.debug
2+
3+
import com.evsuite.hardware.probe.VendorSurfaceRules
4+
5+
/**
6+
* The Diagnostic tab's "Capture before" / "Capture after" pair (CR-045, CR-046), kept for the
7+
* export. `VendorSurfaceProbe.capture()` calls value getters only — its rules refuse every
8+
* setter. In memory: a capture pair is taken minutes before its export, never across a restart.
9+
*/
10+
object SurfaceCaptures {
11+
12+
@Volatile var before: Map<String, String>? = null
13+
@Volatile var after: Map<String, String>? = null
14+
15+
/** The export section body, or null when nothing was captured. */
16+
fun report(): String? {
17+
val b = before
18+
val a = after
19+
return when {
20+
b == null && a == null -> null
21+
b == null || a == null -> "only the ${if (b == null) "after" else "before"} capture was taken"
22+
else -> VendorSurfaceRules.formatDiff(VendorSurfaceRules.diff(b, a), b.size, a.size).trimEnd()
23+
}
24+
}
25+
}

‎app/src/main/java/com/evsuite/tasker/service/TaskerVehicleService.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.evsuite.hardware.PhysicalButtonEventDecoder
1717
import com.evsuite.hardware.VehicleWriteGate
1818
import com.evsuite.tasker.model.RuleTrigger
1919
import com.evsuite.tasker.store.AppState
20+
import com.evsuite.tasker.store.PowerCycleStore
2021
import com.evsuite.tasker.util.BtDevices
2122
import com.evsuite.tasker.util.BtMessaging
2223
import com.evsuite.tasker.util.CarLocation
@@ -150,6 +151,14 @@ class TaskerVehicleService : Service() {
150151
}
151152
}
152153

154+
/** RI-004: proves the head unit is still running, on the sampling thread. No wakelock. */
155+
private val heartbeat = object : Runnable {
156+
override fun run() {
157+
PowerCycleStore.heartbeat(applicationContext)
158+
onboardHandler.postDelayed(this, PowerCycleStore.HEARTBEAT_MS)
159+
}
160+
}
161+
153162
override fun onBind(intent: Intent?): IBinder? = null
154163

155164
override fun onCreate() {
@@ -193,6 +202,9 @@ class TaskerVehicleService : Service() {
193202
CarLocation.startTracking(applicationContext)
194203
registerBtReceiver()
195204
registerHardkeyReceiver()
205+
// Before the listener: the first beat after a restart is what closes the previous
206+
// cycle, and an OFF reported right away belongs to this one.
207+
heartbeat.run()
196208
registerIgnitionListener()
197209
// START_STICKY means this service can be recreated mid-drive. Waiting for the next
198210
// IGNITION_ON would then leave the whole drive unsampled, and every "phone on board"
@@ -237,6 +249,7 @@ class TaskerVehicleService : Service() {
237249
onboardHandler.removeCallbacks(onboardSampler)
238250
onboardHandler.removeCallbacks(parkSampler)
239251
onboardHandler.removeCallbacks(locationSampler)
252+
onboardHandler.removeCallbacks(heartbeat)
240253
onboardThread.quitSafely()
241254
}
242255

@@ -320,6 +333,7 @@ class TaskerVehicleService : Service() {
320333
startOnboardSampling()
321334
} else {
322335
stopOnboardSampling()
336+
PowerCycleStore.ignitionOff(applicationContext)
323337
}
324338
runRulesFor(trigger, "Ignition $state")
325339
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.evsuite.tasker.store
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import android.os.SystemClock
6+
import java.text.SimpleDateFormat
7+
import java.util.Date
8+
import java.util.Locale
9+
import kotlin.math.abs
10+
11+
/**
12+
* How long the head unit keeps running after the ignition goes off (RI-004).
13+
*
14+
* The vehicle service writes the wall time of each ignition-OFF and a heartbeat every
15+
* [HEARTBEAT_MS]. A heartbeat that finds the previous one more than [GAP_MS] away (a reboot,
16+
* a suspend, or a clock jump) closes the previous cycle: its OFF and its last heartbeat are
17+
* kept for the Diagnostic `POWER_CYCLE` row. The difference is the post-off run window, to
18+
* one heartbeat. App-private prefs, `apply()` only; nothing here touches the car.
19+
*/
20+
object PowerCycleStore {
21+
22+
const val HEARTBEAT_MS = 30_000L
23+
private const val GAP_MS = 3 * HEARTBEAT_MS
24+
25+
private const val PREFS = "ev_tasker_power_cycle"
26+
private const val KEY_OFF = "ignition_off"
27+
private const val KEY_BEAT = "heartbeat"
28+
private const val KEY_PREV_OFF = "previous_ignition_off"
29+
private const val KEY_PREV_BEAT = "previous_heartbeat"
30+
private const val KEY_RESUMED = "resumed"
31+
32+
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
33+
34+
fun ignitionOff(context: Context, now: Long = System.currentTimeMillis()) {
35+
prefs(context).edit().putLong(KEY_OFF, now).apply()
36+
}
37+
38+
fun heartbeat(context: Context, now: Long = System.currentTimeMillis()) {
39+
val p = prefs(context)
40+
val edit = p.edit().putLong(KEY_BEAT, now)
41+
val last = p.long(KEY_BEAT)
42+
if (last != null && abs(now - last) > GAP_MS) {
43+
// The OFF moves with its cycle, so a next cycle without one prints none.
44+
edit.putLong(KEY_PREV_BEAT, last).putLong(KEY_RESUMED, now).remove(KEY_OFF)
45+
p.long(KEY_OFF)?.let { edit.putLong(KEY_PREV_OFF, it) } ?: edit.remove(KEY_PREV_OFF)
46+
}
47+
edit.apply()
48+
}
49+
50+
fun describe(context: Context): String {
51+
val p = prefs(context)
52+
val boot = System.currentTimeMillis() - SystemClock.elapsedRealtime()
53+
return format(p.long(KEY_PREV_OFF), p.long(KEY_PREV_BEAT), boot, p.long(KEY_RESUMED))
54+
}
55+
56+
/** `previous: off <t0>, last heartbeat <t1> (+Δs), boot <t2>, resumed <t3>`. */
57+
internal fun format(prevOff: Long?, prevBeat: Long?, boot: Long, resumed: Long?): String {
58+
val delta = if (prevOff != null && prevBeat != null) " (+${(prevBeat - prevOff) / 1000} s)" else ""
59+
return "previous: off ${time(prevOff)}, last heartbeat ${time(prevBeat)}$delta, " +
60+
"boot ${time(boot)}, resumed ${time(resumed)}"
61+
}
62+
63+
private fun time(at: Long?): String =
64+
at?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(it)) } ?: "none"
65+
66+
private fun SharedPreferences.long(key: String): Long? = if (contains(key)) getLong(key, 0) else null
67+
}

‎app/src/main/java/com/evsuite/tasker/ui/DiagnosticFragment.kt‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import com.evsuite.hardware.GlassProbe
1616
import com.evsuite.hardware.catalog.ActionType
1717
import com.evsuite.hardware.catalog.ConditionType
1818
import com.evsuite.hardware.catalog.ValueKind
19+
import com.evsuite.hardware.probe.VendorSurfaceProbe
20+
import com.evsuite.hardware.probe.VendorSurfaceRules
1921
import com.evsuite.hardware.saic.SaicVehicleControl
2022
import com.evsuite.tasker.R
2123
import com.evsuite.tasker.bridge.BridgeContract
@@ -25,6 +27,7 @@ import com.evsuite.tasker.databinding.ItemDiagnosticHeaderBinding
2527
import com.evsuite.tasker.debug.DebugReport
2628
import com.evsuite.tasker.debug.DiagnosticProbe
2729
import com.evsuite.tasker.debug.Diagnostics
30+
import com.evsuite.tasker.debug.SurfaceCaptures
2831
import com.evsuite.tasker.store.SupportChecker
2932
import com.evsuite.tasker.store.SupportStore
3033
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -65,6 +68,9 @@ class DiagnosticFragment : Fragment() {
6568
binding.exportButton.setOnClickListener { export() }
6669
binding.checkSupportButton.setOnClickListener { checkSupport() }
6770
binding.glassProbeButton.setOnClickListener { confirmGlassProbe() }
71+
binding.surfaceBeforeButton.setOnClickListener { captureSurface(before = true) }
72+
binding.surfaceAfterButton.setOnClickListener { captureSurface(before = false) }
73+
binding.surfaceAfterButton.isEnabled = SurfaceCaptures.before != null
6874
load()
6975
showSupport()
7076
showGlass()
@@ -210,6 +216,38 @@ class DiagnosticFragment : Fragment() {
210216
.show()
211217
}
212218

219+
// ── Vendor surface (CR-045, CR-046) ──────────────────────────────────────
220+
//
221+
// Read everything, let the driver flip one setting on the car's own screen, read again:
222+
// the export lists what changed. Getters only — VendorSurfaceRules refuses every setter.
223+
224+
private fun captureSurface(before: Boolean) {
225+
binding.surfaceStatus.setText(R.string.diag_surface_running)
226+
binding.surfaceBeforeButton.isEnabled = false
227+
binding.surfaceAfterButton.isEnabled = false
228+
// Up to VendorSurfaceProbe.BUDGET_MS of binder reads.
229+
thread(name = "mg4-tasker-surface") {
230+
// A throw on this thread would take the app down; a failed capture is a value too.
231+
val values = runCatching { VendorSurfaceProbe.capture() }
232+
.getOrElse { mapOf("capture" to "ERR ${it.javaClass.simpleName}") }
233+
if (before) {
234+
SurfaceCaptures.before = values
235+
SurfaceCaptures.after = null
236+
} else {
237+
SurfaceCaptures.after = values
238+
}
239+
val changed = SurfaceCaptures.before?.let { VendorSurfaceRules.diff(it, values).size } ?: 0
240+
Handler(Looper.getMainLooper()).post {
241+
if (_binding == null) return@post
242+
binding.surfaceBeforeButton.isEnabled = true
243+
binding.surfaceAfterButton.isEnabled = true
244+
binding.surfaceStatus.text =
245+
if (before) getString(R.string.diag_surface_before_done, values.size)
246+
else getString(R.string.diag_surface_after_done, values.size, changed)
247+
}
248+
}
249+
}
250+
213251
private fun load() {
214252
binding.summaryStatus.setText(R.string.diag_reading)
215253
binding.bridgeHint.visibility = View.GONE
@@ -315,6 +353,9 @@ class DiagnosticFragment : Fragment() {
315353
DiagnosticProbe.Env.EXTERIOR_LIGHTS -> R.string.diag_env_lights
316354
DiagnosticProbe.Env.FRONT_DOORS -> R.string.diag_env_doors
317355
DiagnosticProbe.Env.CLIMATE_ZONES -> R.string.diag_env_climate_zones
356+
DiagnosticProbe.Env.RADIO_STATE -> R.string.diag_env_radio_state
357+
DiagnosticProbe.Env.OEM_SCREENS -> R.string.diag_env_oem_screens
358+
DiagnosticProbe.Env.POWER_CYCLE -> R.string.diag_env_power_cycle
318359
}
319360
)
320361

@@ -356,7 +397,10 @@ class DiagnosticFragment : Fragment() {
356397
DiagnosticProbe.Env.GLASS_AND_LOCKS,
357398
DiagnosticProbe.Env.EXTERIOR_LIGHTS,
358399
DiagnosticProbe.Env.FRONT_DOORS,
359-
DiagnosticProbe.Env.CLIMATE_ZONES -> R.string.diag_bt_off
400+
DiagnosticProbe.Env.CLIMATE_ZONES,
401+
DiagnosticProbe.Env.RADIO_STATE,
402+
DiagnosticProbe.Env.OEM_SCREENS,
403+
DiagnosticProbe.Env.POWER_CYCLE -> R.string.diag_bt_off
360404
}
361405
)
362406

@@ -508,6 +552,9 @@ class DiagnosticFragment : Fragment() {
508552
DiagnosticProbe.Env.EXTERIOR_LIGHTS,
509553
DiagnosticProbe.Env.FRONT_DOORS,
510554
DiagnosticProbe.Env.CLIMATE_ZONES,
555+
DiagnosticProbe.Env.RADIO_STATE,
556+
DiagnosticProbe.Env.OEM_SCREENS,
557+
DiagnosticProbe.Env.POWER_CYCLE,
511558
)
512559
}
513560
}

0 commit comments

Comments
 (0)