Skip to content

Commit e26f81c

Browse files
committed
feat(diagnostics): probe head-unit navigation guidance
CP-040 asked whether a route source exists that does not breach the read-only, offline boundary. One does. The adapter service fans remaining distance, remaining time, guidance status and road name out to every registered listener, so remaining distance costs no permission, no network and no account. Adds an unstable-channel probe that arms the listener while parked, records the fan-out over a drive into a bounded ring, and hands back a copyable trace. It is reachable only from the existing evidence capture screen; the stable channel gains no class and no capability. Nothing is presented as a value. The distance callbacks carry no proven unit and the status codes no documented meaning, so readings stay raw and the trace states the unit question rather than hiding it. Cost while running: the listener is push-driven and free when the navigation is silent; the screen adds one 1 Hz tick that reads two volatile fields, a fifth of what the dashboard already does. No file is written and no service is started. analysis/CP-040_route_source.md records the transaction map, the scored comparison, the two traps avoided — the command-channel interface that looks like the observer one, and the fan-out lock a slow listener would stall — and what stays unproven until the vehicle run. Refs: CP-040
1 parent fa68c80 commit e26f81c

9 files changed

Lines changed: 488 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- **A route source exists after all, and the unstable channel can prove it.** The head unit's
10+
adapter service fans remaining distance, remaining time, guidance status and road name out to
11+
any registered listener, so a route no longer requires a network, a permission or an account.
12+
A parked-armed probe in the unstable channel records that fan-out over a drive and reports it
13+
as a copyable trace. Nothing is displayed as a value: the distance callbacks carry no proven
14+
unit and the status codes no documented meaning, so every reading stays raw pending the
15+
on-vehicle run. The stable channel is unchanged and gains no capability.
16+
717
### Security
818

919
- The application declares an empty task affinity, reducing launcher-task hijacking exposure on

‎analysis/CP-040_route_source.md‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# CP-040 — Route and destination source
2+
3+
Date: 2026-09-02
4+
5+
Decision owner: pending. This document states the finding and the recommendation; the
6+
network question at the end of it is the owner's, not this session's.
7+
8+
Firmware examined: `saicadapterservice_overseas_eh32` (R69 EH32), decompiled sources under
9+
workspace `apks/`.
10+
11+
## Verdict in one line
12+
13+
The head unit **does** publish remaining distance and remaining time to the destination, to
14+
any process that registers as a listener on its general service — no Android permission, no
15+
network, no account. That closes the question CP-040 was written to answer. It does **not**
16+
close Phase 4, because it carries no elevation, no chargers and no alternative routes.
17+
18+
## What was actually found
19+
20+
`SaicNav` recorded that the map adapter answers no synchronous question about a trip. That
21+
is correct and remains correct. What it did not record is where the guidance goes instead.
22+
23+
The navigation app calls into `com.saicmotor.adapterservice`:
24+
25+
| Call | Transaction | Payload |
26+
| --- | --- | --- |
27+
| `remainingDistanceChange` | `IMapService` 39 | one int, logged as `remainingDistance` |
28+
| `remainingTimesChange` | `IMapService` 38 | one int, logged as `remainingMinutes` |
29+
| `guideStatusChange` | `IMapService` 29 | one int |
30+
| `guideInfosChange` | `IMapService` 30 | icon, distance to next manoeuvre, direction text |
31+
32+
`MapService` stores each of these and forwards it to `GeneralService`, which fans it out over
33+
a `RemoteCallbackList` to every registered `IGeneralNotificationListener`:
34+
35+
| Callback | Transaction |
36+
| --- | --- |
37+
| `guideStatusChange` | 1 |
38+
| `guideInfosChange` | 2 |
39+
| `roadInfoChange` | 3 |
40+
| `remainingTimesChange` | 12 |
41+
| `remainingDistanceChange` | 13 |
42+
43+
Registration is `IGeneralService.registerNotificationListener`, transaction 1, on
44+
`com.saicmotor.adapterservice/.services.GeneralService`.
45+
46+
Implemented as `EVHardware/lib/.../saic/SaicNavGuidance.kt`; the probe that captures it is
47+
`EVChargePilot/app/src/unstable/.../NavGuidanceProbeActivity.kt`.
48+
49+
## Two traps found on the way, both avoided
50+
51+
**`IMapNotificationListener` is the wrong direction.** It is tempting because
52+
`IMapService.registerNotificationListener` is transaction 1 and the name matches. But that
53+
interface carries `goHome`, `zoomIn`, `stopNav`, `setFastestRoute` and `startNavFromEVRout` —
54+
it is the channel the head unit uses to *command the navigation app*, not a feed of guidance.
55+
Registering on it would make this app impersonate a navigation provider, and every method on
56+
it is a write. It is out of bounds for a read-only app. `IGeneralNotificationListener` is the
57+
observer side and is what is used.
58+
59+
**The callbacks are not `oneway`.** The generated proxy calls
60+
`transact(code, data, reply, 0)` and then `readException()`, and `GeneralService` fans out
61+
while holding the lock on its callback list. A listener that blocks therefore stalls every
62+
other consumer of guidance, the instrument cluster included. `SaicNavGuidance` does one
63+
parcel read and one volatile write per callback and nothing else — no I/O, no lock, no
64+
allocation beyond the immutable state object. Unrecognised transactions are answered without
65+
being parsed, so unrelated callbacks cost a reply and nothing more.
66+
67+
Registration is additive: `RemoteCallbackList.register` appends and the list drops a dead
68+
listener on its own, so this displaces neither the navigation app nor the cluster.
69+
70+
## What is still unproven
71+
72+
- **The unit of `remainingDistance`.** The service logs the value without naming a unit and
73+
`distanceUnitChange` exists as a separate concern. Metres and kilometres are both plausible
74+
and the difference is a factor of a thousand. Nothing is converted anywhere in the code;
75+
the value is carried as `remainingDistanceRaw`.
76+
- **The meaning of `guideStatus` codes.** Undocumented; carried raw.
77+
- **Behaviour with no guidance running.** If the navigation app publishes nothing when idle,
78+
the feature only exists for drivers who navigate with the OEM app. This is the single most
79+
decision-relevant unknown and the probe is built to record it.
80+
- **Whether a third-party navigation app feeds the same path.** TomTom broadcasts
81+
(`com.tomtom.navigation.GUIDE_INFO`) reach `MapService`, so at least one non-SAIC app does.
82+
83+
None of these can be settled off-vehicle, which is why CP-040 carries `on_vehicle_required`.
84+
85+
## Scored comparison
86+
87+
Scores are against CP-040's four criteria. "Boundary" means the read-only, offline,
88+
minimal-permission boundary stated in `PRODUCT.md`.
89+
90+
| Source | Offline | Permission cost | Firmware stability | Boundary | Verdict |
91+
| --- | --- | --- | --- | --- | --- |
92+
| **OEM adapter guidance listener** | full | none | unproven; private interface, transaction codes are firmware-specific and must be evidence-gated per generation | intact — read-only, additive registration | **recommended for remaining distance and time** |
93+
| Manual destination entry + straight-line estimate | full | none | not firmware-dependent | intact | fallback only; a great-circle distance is wrong by 15–30 % on real roads and cannot see terrain |
94+
| Destination handed in by intent from another app | full | none | not firmware-dependent | intact | no such app exists today; keep as an interface, not a plan |
95+
| External routing engine (ORS, GraphHopper, Valhalla) | no | `INTERNET`, plus API-key handling | stable | **breaks it** — stable variant becomes networked | the only source that yields elevation and alternative routes |
96+
| ABRP `get_next_charge` | no | `INTERNET` | undocumented contract | breaks it, and adds an unagreed dependency | rejected, per `PLAN.md` |
97+
| Do not build Phase 4 | — | — | — | intact | still available, and still acceptable |
98+
99+
## Recommendation
100+
101+
**Take the adapter listener, and split Phase 4 in two.**
102+
103+
1. **CP-041 arrival SOC becomes buildable** on remaining distance from the listener, once the
104+
probe returns a unit and an idle-behaviour answer from the vehicle. It stays offline,
105+
permission-free and inside the stated boundary. Its band must widen for the absent grade
106+
term, per the CP-031 decision.
107+
108+
2. **Everything else in the driver's stated goal stays blocked on a network decision.**
109+
Elevation profile, charger locations and "via the departmental road instead" cannot come
110+
from the head unit. They need a routing engine, and that means `INTERNET` in the stable
111+
variant, a `PRODUCT.md` rewrite and a fresh CP-091 pass. That decision is not taken here.
112+
113+
## Do-not-build option
114+
115+
If the probe shows the adapter publishes nothing when the OEM navigation is idle, and the
116+
driver does not use the OEM navigation, then the listener is dead weight: delete
117+
`SaicNavGuidance` and the probe, mark CP-041 and CP-042 `blocked` permanently, and ship
118+
without Phase 4. That remains an acceptable outcome.
119+
120+
## Security note
121+
122+
No permission is added by this work; `mise run permissions` passes unchanged. The listener
123+
exists only in the unstable variant's reachable UI. Should CP-041 promote it to stable, the
124+
promotion needs its own CP-091 entry, because a stable build would then hold a registration
125+
on a head-unit service for the life of the process.
126+
127+
## Next step
128+
129+
Run the probe on the vehicle: park, arm, start a guidance in the car's own navigation, drive a
130+
leg whose distance is known independently, then park and copy the trace. The trace answers the
131+
unit question and the idle question together.

‎app/src/unstable/AndroidManifest.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,11 @@
99
android:exported="false"
1010
android:configChanges="uiMode"
1111
android:screenOrientation="landscape" />
12+
13+
<activity
14+
android:name=".NavGuidanceProbeActivity"
15+
android:exported="false"
16+
android:configChanges="uiMode"
17+
android:screenOrientation="landscape" />
1218
</application>
1319
</manifest>

‎app/src/unstable/java/com/evsuite/chargepilot/EvidenceCaptureActivity.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.evsuite.chargepilot
22

33
import android.content.ClipData
44
import android.content.ClipboardManager
5+
import android.content.Intent
56
import android.os.Bundle
67
import android.os.SystemClock
78
import androidx.appcompat.app.AppCompatActivity
@@ -59,6 +60,9 @@ class EvidenceCaptureActivity : AppCompatActivity() {
5960
fileStore = EvidenceCaptureFileStore(File(filesDir, "evidence"))
6061
binding.captureAction.setOnClickListener { requestToggle() }
6162
binding.copyAction.setOnClickListener { copyMarkdown() }
63+
binding.navProbeAction.setOnClickListener {
64+
startActivity(Intent(this, NavGuidanceProbeActivity::class.java))
65+
}
6266
render(CaptureViewState.empty())
6367
}
6468

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.content.ClipData
4+
import android.content.ClipboardManager
5+
import android.os.Bundle
6+
import android.os.Handler
7+
import android.os.Looper
8+
import android.os.SystemClock
9+
import androidx.appcompat.app.AppCompatActivity
10+
import com.evsuite.chargepilot.databinding.ActivityNavGuidanceProbeBinding
11+
import com.evsuite.hardware.saic.NavGuidance
12+
import com.evsuite.hardware.saic.SaicNavGuidance
13+
import com.evsuite.hardware.telemetry.EnergyTelemetryReader
14+
import com.google.android.material.snackbar.Snackbar
15+
import java.util.ArrayDeque
16+
import java.util.Locale
17+
18+
/**
19+
* Records what the head unit's navigation actually reports during a drive.
20+
*
21+
* This is the CP-040 instrument. It answers three questions a decision needs and that no
22+
* amount of desk work can settle: whether the adapter accepts the registration at all, what
23+
* the remaining-distance callback carries and in which unit, and — the one that decides
24+
* whether the feature exists — whether anything arrives when no guidance is running.
25+
*
26+
* **Cost while it runs.** The listener itself is push-driven and costs nothing when the
27+
* navigation app is silent. This screen adds one 1 Hz tick that reads two volatile fields and
28+
* compares them, which is a fifth of what the dashboard already does. The trace is a bounded
29+
* ring; a long drive overwrites its oldest lines rather than growing. Nothing is written to
30+
* disk and no service is started.
31+
*
32+
* The tick outlives [onStop] on purpose: a capture is meant to survive the screen going dark
33+
* over three hours of motorway. It stops at [onDestroy], and so does the registration.
34+
*/
35+
class NavGuidanceProbeActivity : AppCompatActivity() {
36+
37+
private lateinit var binding: ActivityNavGuidanceProbeBinding
38+
private lateinit var reader: EnergyTelemetryReader
39+
40+
private val ticker = Handler(Looper.getMainLooper())
41+
private val trace = ArrayDeque<String>()
42+
private var lastEvents = -1
43+
private var visible = false
44+
private var startedAtElapsedMs: Long? = null
45+
46+
override fun onCreate(savedInstanceState: Bundle?) {
47+
super.onCreate(savedInstanceState)
48+
binding = ActivityNavGuidanceProbeBinding.inflate(layoutInflater)
49+
setContentView(binding.root)
50+
reader = EnergyTelemetryReader(applicationContext)
51+
SaicNavGuidance.connect(applicationContext)
52+
binding.probeAction.setOnClickListener { toggle() }
53+
binding.copyAction.setOnClickListener { copyTrace() }
54+
ticker.post(tick)
55+
render()
56+
}
57+
58+
override fun onStart() {
59+
super.onStart()
60+
visible = true
61+
render()
62+
}
63+
64+
override fun onStop() {
65+
visible = false
66+
super.onStop()
67+
}
68+
69+
override fun onDestroy() {
70+
ticker.removeCallbacks(tick)
71+
SaicNavGuidance.stop()
72+
super.onDestroy()
73+
}
74+
75+
private val tick = object : Runnable {
76+
override fun run() {
77+
record(SaicNavGuidance.latest())
78+
if (visible) render()
79+
ticker.postDelayed(this, TICK_MS)
80+
}
81+
}
82+
83+
/**
84+
* Appends a line only when the listener actually saw something new.
85+
*
86+
* A drive that produces no line is the finding, not a failure of this screen — which is
87+
* why the trace is never padded with repeats of an unchanged state.
88+
*/
89+
private fun record(guidance: NavGuidance) {
90+
if (guidance.events == lastEvents) return
91+
lastEvents = guidance.events
92+
if (guidance.events == 0) return
93+
val since = startedAtElapsedMs?.let { (SystemClock.elapsedRealtime() - it) / 1_000L } ?: 0L
94+
if (trace.size >= TRACE_LINES) trace.removeFirst()
95+
trace.addLast(
96+
String.format(
97+
Locale.ROOT,
98+
"%5ds n=%-4d status=%-4s dist=%-8s min=%-6s turn=%-8s road=%s",
99+
since, guidance.events,
100+
guidance.guideStatus.show(),
101+
guidance.remainingDistanceRaw.show(),
102+
guidance.remainingMinutes.show(),
103+
guidance.nextTurnDistanceRaw.show(),
104+
guidance.road ?: guidance.direction ?: DASH,
105+
)
106+
)
107+
}
108+
109+
/** Rechecks speed at the moment of the press; a disabled button is not a safety boundary. */
110+
private fun toggle() {
111+
if (SaicNavGuidance.isListening) {
112+
SaicNavGuidance.stop()
113+
startedAtElapsedMs = null
114+
lastEvents = -1
115+
render()
116+
return
117+
}
118+
val speed = runCatching { reader.read().speedKmh }.getOrNull()
119+
if (EvidenceCapturePolicy.gate(speed) != EvidenceCaptureGate.PARKED) {
120+
Snackbar.make(binding.root, R.string.nav_probe_refused, Snackbar.LENGTH_LONG).show()
121+
return
122+
}
123+
if (SaicNavGuidance.start()) {
124+
startedAtElapsedMs = SystemClock.elapsedRealtime()
125+
trace.clear()
126+
lastEvents = -1
127+
}
128+
render()
129+
}
130+
131+
private fun copyTrace() {
132+
val clipboard = getSystemService(ClipboardManager::class.java) ?: return
133+
clipboard.setPrimaryClip(ClipData.newPlainText("nav-guidance", markdown()))
134+
Snackbar.make(binding.root, R.string.nav_probe_copied, Snackbar.LENGTH_SHORT).show()
135+
}
136+
137+
/**
138+
* The report a CP-040 decision is written from.
139+
*
140+
* It states the unit question rather than hiding it: whoever reads the trace has to say
141+
* what `dist` was in, and the only way to know is to compare it against a distance the
142+
* driver already knows.
143+
*/
144+
private fun markdown(): String = buildString {
145+
append("# CP-040 — navigation guidance trace\n\n")
146+
append("- adapter bound: ${SaicNavGuidance.isAvailable}\n")
147+
append("- listener registered: ${SaicNavGuidance.isListening}\n")
148+
append("- callbacks seen: ${SaicNavGuidance.latest().events}\n")
149+
append("- trace lines: ${trace.size}${if (trace.size >= TRACE_LINES) " (oldest dropped)" else ""}\n\n")
150+
append("`dist` and `turn` are raw callback values. Their unit is unproven — state it\n")
151+
append("by comparing against a distance known independently before using any of this.\n\n")
152+
append("```\n")
153+
trace.forEach { append(it).append('\n') }
154+
append("```\n")
155+
}
156+
157+
private fun render() {
158+
val guidance = SaicNavGuidance.latest()
159+
val listening = SaicNavGuidance.isListening
160+
binding.probeAction.setText(
161+
if (listening) R.string.nav_probe_stop else R.string.nav_probe_start
162+
)
163+
binding.probeStatus.text = when {
164+
!SaicNavGuidance.isAvailable -> getString(R.string.nav_probe_unavailable)
165+
!listening -> getString(R.string.nav_probe_idle)
166+
guidance.events == 0 -> getString(R.string.nav_probe_silent)
167+
else -> getString(R.string.nav_probe_receiving, guidance.events)
168+
}
169+
binding.probeHint.setText(
170+
when {
171+
!SaicNavGuidance.isAvailable -> R.string.nav_probe_hint_unavailable
172+
listening -> R.string.nav_probe_hint_running
173+
else -> R.string.nav_probe_hint_ready
174+
}
175+
)
176+
binding.probeTrace.text = if (trace.isEmpty()) {
177+
getString(R.string.nav_probe_no_data)
178+
} else {
179+
trace.joinToString("\n")
180+
}
181+
binding.copyAction.isEnabled = trace.isNotEmpty()
182+
}
183+
184+
private fun Int?.show(): String = this?.toString() ?: DASH
185+
186+
private companion object {
187+
const val TICK_MS = 1_000L
188+
/** Three hours of change at one line per second would not fit; the newest lines win. */
189+
const val TRACE_LINES = 300
190+
const val DASH = "—"
191+
}
192+
}

‎app/src/unstable/res/layout/activity_evidence_capture.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@
112112
android:layout_height="@dimen/touch_target"
113113
android:layout_marginTop="@dimen/spacing_sm"
114114
android:text="@string/evidence_copy" />
115+
116+
<com.google.android.material.button.MaterialButton
117+
android:id="@+id/navProbeAction"
118+
style="@style/Widget.EV.Button.Outlined"
119+
android:layout_width="match_parent"
120+
android:layout_height="@dimen/touch_target"
121+
android:layout_marginTop="@dimen/spacing_sm"
122+
android:text="@string/nav_probe_open" />
115123
</LinearLayout>
116124
</LinearLayout>
117125
</LinearLayout>

0 commit comments

Comments
 (0)