Skip to content

Commit 33839ab

Browse files
malysclaude
andcommitted
release: EVABRPUploader 2.1.7 — keep the broadcast, and time the query
Two things the last log said, one of which contradicts the note that shipped with 2.1.6. That release claimed the map service never bound. The log says map=bound: the binder is alive and it is the query that comes back with nothing. Different failure, different fix, and the claim was a guess dressed as a finding. The query is now timed and the elapsed time goes in the log. It is bounded by a two-second wait inside EVHardware, so a null returning immediately was refused or answered undecodably, and a null returning at the bound is a service that did not answer in time. Nothing visible from outside the library tells those apart, which is exactly how the wrong note got written. The broadcast is real. The action is confirmed by the vendor-service work in EVHardware: the weather app sends com.saicmotor.weather when it refreshes itself, on no schedule this app can ask for. 2.1.6 listened and then forgot between refreshes — and the app restarts every time the driver opens it to read the log, which is the worst possible moment to begin waiting for one. The last reading now survives a restart for two hours, and the platform is asked at start-up, and again on any upload still without a temperature, for a broadcast it may already be holding. That costs nothing and answers at once if the weather app sends its update sticky. A reading now carries its age in the log as well as its place, because where a value came from does not say whether it still describes the day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 48559a6 commit 33839ab

5 files changed

Lines changed: 138 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to this project are documented here. Format follows
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [2.1.7] - 2026-08-26
8+
9+
### Fixed
10+
11+
- **The weather broadcast is real but rare, and the app forgot it between restarts.** The action
12+
is confirmed: the head unit's weather app sends `com.saicmotor.weather` when *it* refreshes,
13+
not on any cadence this app can ask for. 2.1.6 listened and gave up between refreshes, and the
14+
app is restarted every time the driver opens it to look at the log — which is the worst moment
15+
to start waiting. The last reading is now kept across restarts for two hours, and the platform
16+
is asked at start-up and on every temperature-less upload for a held broadcast, which costs
17+
nothing and answers immediately if the weather app sends its update sticky.
18+
19+
### Changed
20+
21+
- **`map=bound` in 2.1.6 corrected the release note that shipped with it.** That release said the
22+
map service never bound; the log showed the binder alive and the *query* returning nothing,
23+
which is a different failure with a different fix. The map query is now timed, and the elapsed
24+
time appears in the log — the query is bounded by a two-second wait inside EVHardware, so a
25+
null returning at once was refused or answered undecodably while a null returning at the bound
26+
is a service that did not answer in time. Nothing outside the library distinguishes them, and
27+
guessing which one it was is what produced the wrong note.
28+
- The broadcast reading now carries its age in the log (`bcast=Toulouse/12min`): where a value
29+
came from does not say whether it still describes the day.
30+
731
## [2.1.6] - 2026-08-26
832

933
### Fixed

‎app/build.gradle‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ android {
3434
applicationId "com.evsuite.abrp"
3535
minSdk 27
3636
targetSdk 36
37-
versionCode 14
38-
versionName "2.1.6"
37+
versionCode 15
38+
versionName "2.1.7"
3939

4040
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
4141
}

‎app/src/main/java/com/evsuite/abrp/AbrpUploadService.java‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ public class AbrpUploadService extends Service {
111111
* enough for ten minutes to matter to a consumption model.
112112
*/
113113
private static final long WEATHER_TTL_MS = 10 * 60 * 1000L;
114+
/**
115+
* How long a broadcast temperature keeps standing in for the car's thermometer.
116+
*
117+
* The weather app broadcasts when it refreshes itself, not on a cadence this app can ask
118+
* for, so a reading has to outlive the moment it arrived or it would be useless between
119+
* refreshes. Two hours is long enough to bridge them and short enough that the value has
120+
* not stopped describing the day.
121+
*/
122+
private static final long WEATHER_MAX_AGE_MS = 2 * 60 * 60 * 1000L;
123+
private static final String KEY_WEATHER_TEMP = "weather_temp_c";
124+
private static final String KEY_WEATHER_PLACE = "weather_place";
125+
private static final String KEY_WEATHER_MS = "weather_received_ms";
114126

115127
// Set from the main thread (LocationListener uses Main looper), read from
116128
// the scheduler thread inside doUpload. volatile is sufficient since Location
@@ -149,9 +161,12 @@ public class AbrpUploadService extends Service {
149161
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
150162
@Override public void onReceive(Context context, Intent intent) {
151163
headUnitWeather.accept(intent);
164+
saveWeather();
152165
}
153166
};
154167
private volatile boolean weatherReceiverRegistered = false;
168+
/** How long the last map-service query took, or -1 when it was never made. */
169+
private volatile long lastQueryMs = -1L;
155170

156171
// ---------- Lifecycle ----------
157172

@@ -470,7 +485,10 @@ private void doUpload() {
470485
// The broadcast first. It is what fills the head unit's own status bar, so on this
471486
// car it is the source known to be producing a number, where the map service's
472487
// query never even bound.
473-
Float broadcast = headUnitWeather.temperatureC();
488+
// The broadcast arrives when the weather app feels like it, so a reading that
489+
// never came is retried here rather than only at start-up.
490+
if (headUnitWeather.temperatureC() == null) probeStickyWeather();
491+
Float broadcast = weatherIsFresh() ? headUnitWeather.temperatureC() : null;
474492
if (TelemetryPayload.isPlausibleTemp(broadcast)) {
475493
extTemp = broadcast;
476494
tempSource = "bcast:" + headUnitWeather.diagnostic();
@@ -484,7 +502,8 @@ private void doUpload() {
484502
// "the broadcast never arrives" and "it arrives in a shape this cannot
485503
// read" — and nothing on the ABRP side tells them apart.
486504
tempSource = "none bcast=" + headUnitWeather.diagnostic()
487-
+ " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound");
505+
+ " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound")
506+
+ (lastQueryMs < 0 ? "" : "/" + lastQueryMs + "ms");
488507
}
489508
}
490509
}
@@ -547,18 +566,66 @@ private void doUpload() {
547566
* through ContextCompat, which picks the right overload for the platform underneath.
548567
*/
549568
private void registerWeatherReceiver() {
569+
restoreWeather();
550570
try {
551571
androidx.core.content.ContextCompat.registerReceiver(
552572
this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION),
553573
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
554574
weatherReceiverRegistered = true;
575+
// A null receiver asks the platform for the last broadcast it is holding without
576+
// subscribing to anything. If the weather app sends its update sticky, this is the
577+
// reading straight away rather than after however long until its next refresh —
578+
// and the app is started to look at the log, which is exactly the wrong moment to
579+
// begin waiting.
580+
probeStickyWeather();
555581
} catch (Exception e) {
556582
// A car whose weather app broadcasts nothing is a car without ext_temp, not a
557583
// reason to lose the upload service.
558584
Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage());
559585
}
560586
}
561587

588+
/** Asks for the last held broadcast, if the platform is holding one. Harmless when not. */
589+
private void probeStickyWeather() {
590+
try {
591+
headUnitWeather.accept(
592+
registerReceiver(null, new IntentFilter(HeadUnitWeather.ACTION)));
593+
saveWeather();
594+
} catch (Exception e) {
595+
Log.w(TAG, "Sticky weather probe failed: " + e.getMessage());
596+
}
597+
}
598+
599+
/**
600+
* Keeps the last reading across a restart.
601+
*
602+
* The broadcast arrives when the weather app refreshes, so a service that forgets on every
603+
* start would spend most of its life with no temperature at all — and the app is restarted
604+
* every time the driver opens it.
605+
*/
606+
private void saveWeather() {
607+
Float celsius = headUnitWeather.temperatureC();
608+
if (celsius == null) return;
609+
prefs.edit()
610+
.putFloat(KEY_WEATHER_TEMP, celsius)
611+
.putString(KEY_WEATHER_PLACE, headUnitWeather.place())
612+
.putLong(KEY_WEATHER_MS, headUnitWeather.receivedMs())
613+
.apply();
614+
}
615+
616+
private void restoreWeather() {
617+
long whenMs = prefs.getLong(KEY_WEATHER_MS, 0L);
618+
if (whenMs <= 0L || System.currentTimeMillis() - whenMs > WEATHER_MAX_AGE_MS) return;
619+
headUnitWeather.restore(prefs.getFloat(KEY_WEATHER_TEMP, 0f),
620+
prefs.getString(KEY_WEATHER_PLACE, ""), whenMs);
621+
}
622+
623+
/** True while the stored reading still describes the day — see {@link #WEATHER_MAX_AGE_MS}. */
624+
private boolean weatherIsFresh() {
625+
long whenMs = headUnitWeather.receivedMs();
626+
return whenMs > 0L && System.currentTimeMillis() - whenMs <= WEATHER_MAX_AGE_MS;
627+
}
628+
562629
/**
563630
* Current conditions where the car is, or null when nothing can answer.
564631
*
@@ -572,9 +639,15 @@ private SaicWeather.Reading weatherAt(Location loc) {
572639
long now = System.currentTimeMillis();
573640
SaicWeather.Reading cached = lastWeather;
574641
if (cached != null && now - lastWeatherMs < WEATHER_TTL_MS) return cached;
575-
if (!SaicWeather.INSTANCE.isAvailable()) return cached;
642+
if (!SaicWeather.INSTANCE.isAvailable()) { lastQueryMs = -1L; return cached; }
643+
// How long the call took is the only thing that separates its failure modes from out
644+
// here: the query is bounded by a two-second wait inside the library, so a null that
645+
// comes back at once was refused or answered undecodably, and a null that comes back
646+
// at the bound is a service that did not answer in time. The library reports neither.
647+
long startedMs = System.currentTimeMillis();
576648
SaicWeather.Reading reading = SaicWeather.INSTANCE.currentAt(
577649
loc.getLatitude(), loc.getLongitude(), weatherLanguage());
650+
lastQueryMs = System.currentTimeMillis() - startedMs;
578651
if (reading == null) return cached;
579652
lastWeather = reading;
580653
lastWeatherMs = now;

‎app/src/main/java/com/evsuite/abrp/HeadUnitWeather.java‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,30 @@ long receivedMs() {
6363
* are indistinguishable from the ABRP side.
6464
*/
6565
String diagnostic() {
66-
if (temperatureC != null) return place.isEmpty() ? "ok" : place;
67-
return problem;
66+
if (temperatureC == null) return problem;
67+
String age = (System.currentTimeMillis() - receivedMs) / 60000 + "min";
68+
return (place.isEmpty() ? "ok" : place) + "/" + age;
6869
}
6970

7071
/** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */
7172
void accept(Intent intent) {
73+
if (intent == null) {
74+
// Only reached from the sticky probe, which answers null when the platform is
75+
// holding no such broadcast. Says nothing about whether one will ever be sent.
76+
if (temperatureC == null) problem = "none held, none sent yet";
77+
return;
78+
}
7279
acceptPayload(payloadOf(intent));
7380
}
7481

82+
/** Restores the last reading across a restart — see the service's prefs. */
83+
void restore(float celsius, String named, long whenMs) {
84+
temperatureC = celsius;
85+
place = named == null ? "" : named;
86+
receivedMs = whenMs;
87+
problem = null;
88+
}
89+
7590
/**
7691
* The half of {@link #accept} that has no Android in it, so the search below is testable
7792
* against the payloads it might be handed rather than only against the one it expects.

‎app/src/test/java/com/evsuite/abrp/HeadUnitWeatherTest.java‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ public void readsATemperatureAndThePlaceItIsFor() {
2424
+ "\"address\":\"Toulouse\",\"conditionType\":1}}"));
2525
assertEquals(27f, weather.temperatureC(), 0.01f);
2626
assertEquals("Toulouse", weather.place());
27-
assertEquals("Toulouse", weather.diagnostic());
27+
// The diagnostic carries the reading's age: a broadcast arrives when the weather app
28+
// refreshes, so "where from" without "how long ago" does not say whether to trust it.
29+
assertEquals("Toulouse/0min", weather.diagnostic());
2830
}
2931

3032
@Test
@@ -88,4 +90,20 @@ public void malformedJsonDoesNotEscapeTheReceiver() {
8890
assertNull(weather.temperatureC());
8991
assertTrue(weather.diagnostic(), weather.diagnostic().contains("reading broadcast"));
9092
}
93+
94+
@Test
95+
public void aRestoredReadingIsReportedWithItsAge() {
96+
HeadUnitWeather weather = new HeadUnitWeather();
97+
weather.restore(19.5f, "Toulouse", System.currentTimeMillis() - 45 * 60 * 1000L);
98+
assertEquals(19.5f, weather.temperatureC(), 0.01f);
99+
assertEquals("Toulouse/45min", weather.diagnostic());
100+
}
101+
102+
@Test
103+
public void anAbsentStickyBroadcastIsNotMistakenForAFailedParse() {
104+
HeadUnitWeather weather = new HeadUnitWeather();
105+
weather.accept(null);
106+
assertNull(weather.temperatureC());
107+
assertEquals("none held, none sent yet", weather.diagnostic());
108+
}
91109
}

0 commit comments

Comments
 (0)