Skip to content

Commit 48559a6

Browse files
malysclaude
andcommitted
release: EVABRPUploader 2.1.6 — the temperature the head unit already knows
The log from 2.1.5 did its job. Every upload listed ext_temp among the omitted fields with no place name beside the fix, which said the map-service query added in that release never bound at all — not that it answered badly. So the second guess at a source was as wrong as the first, and this time there is evidence rather than another guess. The head unit displays an outside temperature in its status bar, continuously, on the same screen as this app. It does not get it from the service that would not bind; it gets it from a broadcast. The uploader now listens for the same one and prefers it, keeping the map query behind it and the vehicle's own sensor ahead of both for a car that implements ENV_OUTSIDE_TEMPERATURE, which this one does not. The payload's shape is unverified — the action and the extra come from reading the head unit, not from a published interface — so it is not parsed against a layout. Any key whose name mentions a temperature, at any depth, in either unit, as a number or as a string. A parser written against a guessed layout fails silently and is indistinguishable from the bug it was meant to fix, which is the mistake this release exists to stop repeating. When a broadcast arrives that it cannot read, it keeps a sample of what it was given and puts that in the log. Every entry now names where the temperature came from, or why nothing did: the difference between a broadcast that never arrives and one that arrives in an unexpected shape is invisible from the ABRP side, and each calls for a different fix. Position is not the problem and the log settled that too: fixes come from the fused provider, accurate to about a metre, seconds old, at the coordinates the car is actually at. Whatever address ABRP is showing, it is not coming from what this app sends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 22579a7 commit 48559a6

6 files changed

Lines changed: 406 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@ 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.6] - 2026-08-26
8+
9+
### Fixed
10+
11+
- **`ext_temp` was still absent: the weather service 2.1.5 added never bound.** The log added in
12+
that release is what showed it — every upload listed `ext_temp` among the omitted fields, with
13+
no place name beside the fix. Meanwhile the head unit's own status bar shows a temperature
14+
continuously, and it does not get it from that service: it gets it from a broadcast. The
15+
uploader now listens for the same one (`com.saicmotor.weather`) and prefers it, falling back
16+
to the map service query and, before either, to the vehicle sensor on a car that has one.
17+
18+
The payload's shape is unverified, so it is read by searching for any key that names a
19+
temperature at any depth rather than by a fixed layout, in either unit, whether the value
20+
arrives as a number or as a string. A parser written against a guessed layout fails silently
21+
and looks exactly like the bug it was meant to fix.
22+
23+
### Added
24+
25+
- **The temperature's source, on every log entry** — `[temp car]`, `[temp bcast:Toulouse]`,
26+
`[temp map:...]`, or when nothing answered, why each one did not: `[temp none bcast=no
27+
broadcast yet map=unbound]`. A payload that arrives but carries no readable temperature is
28+
reported with a sample of what it did carry, which is what a fixed parser would have hidden.
29+
30+
### Notes
31+
32+
- Position is not the problem, and the log settled it: fixes arrive from the `fused` provider,
33+
accurate to about a metre and seconds old, with the coordinates the car is actually at. An
34+
address that looks wrong on ABRP is not coming from what this app sends.
35+
736
## [2.1.5] - 2026-08-26
837

938
### Fixed

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ unit and talks to ABRP directly.
5555
| State of charge | SWI68 vendor EV property (`CarPropertyAdapter`) | SWI68 only |
5656
| Range | SWI68 vendor EV property, falling back to standard AAOS `RANGE_REMAINING` | all supported (vendor first) |
5757
| Speed, parked state | `EVHardware` (per-generation) | all supported |
58-
| Outside temperature | `EVHardware` (standard AAOS), falling back to the head unit's weather service | all supported (the vehicle sensor is absent on MG4) |
58+
| Outside temperature | The head unit's weather broadcast, falling back to its map-service query; the vehicle sensor first where one exists | all supported (the vehicle sensor is absent on MG4) |
5959
| Charging, DC fast charging | Charge port state + charge rate heuristic | where the VHAL implements them |
6060
| Charged energy this session | Charge rate integrated over the session | where the charge rate is readable |
6161
| State of energy, pack capacity, pack temperature | Standard AAOS EV properties | where the VHAL implements them |

‎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 13
38-
versionName "2.1.5"
37+
versionCode 14
38+
versionName "2.1.6"
3939

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

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

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
import android.app.NotificationManager;
66
import android.app.PendingIntent;
77
import android.app.Service;
8+
import android.content.BroadcastReceiver;
9+
import android.content.Context;
810
import android.content.Intent;
11+
import android.content.IntentFilter;
12+
import android.content.SharedPreferences;
913
import android.content.pm.PackageManager;
1014
import android.content.pm.ServiceInfo;
11-
import android.content.SharedPreferences;
1215
import android.location.Location;
1316
import android.location.LocationListener;
1417
import android.location.LocationManager;
@@ -141,6 +144,14 @@ public class AbrpUploadService extends Service {
141144
/** Last head-unit weather reading and when it was taken — see {@link #WEATHER_TTL_MS}. */
142145
private volatile SaicWeather.Reading lastWeather = null;
143146
private volatile long lastWeatherMs = 0L;
147+
/** The head unit's own weather broadcast — the source its status bar uses. */
148+
private final HeadUnitWeather headUnitWeather = new HeadUnitWeather();
149+
private final BroadcastReceiver weatherReceiver = new BroadcastReceiver() {
150+
@Override public void onReceive(Context context, Intent intent) {
151+
headUnitWeather.accept(intent);
152+
}
153+
};
154+
private volatile boolean weatherReceiverRegistered = false;
144155

145156
// ---------- Lifecycle ----------
146157

@@ -174,6 +185,7 @@ public void onCreate() {
174185
// and something else has to. Idempotent, asynchronous, and absent on a car that does
175186
// not have the map stack — in which case ext_temp simply stays unreported.
176187
SaicWeather.INSTANCE.connect(getApplicationContext());
188+
registerWeatherReceiver();
177189

178190
connectCarAdapter();
179191
requestLocationUpdates();
@@ -245,6 +257,10 @@ public void onDestroy() {
245257
if (locationManager != null) {
246258
try { locationManager.removeUpdates(locationListener); } catch (Exception ignored) {}
247259
}
260+
if (weatherReceiverRegistered) {
261+
try { unregisterReceiver(weatherReceiver); } catch (Exception ignored) {}
262+
weatherReceiverRegistered = false;
263+
}
248264
if (mainHandler != null) mainHandler.removeCallbacksAndMessages(null);
249265
running = false;
250266
prefs.edit().putBoolean("service_running", false).apply();
@@ -448,11 +464,28 @@ private void doUpload() {
448464
// ENV_OUTSIDE_TEMPERATURE, so there is no vehicle reading to correct. The head unit's
449465
// weather service answers for a position, and ambient air at the car is what ext_temp
450466
// means — a better figure than a bumper sensor in the sun would give anyway.
467+
String tempSource = "car";
451468
SaicWeather.Reading sky = null;
452469
if (!TelemetryPayload.isPlausibleTemp(extTemp)) {
453-
sky = weatherAt(loc);
454-
if (sky != null && sky.getTemperatureCelsius() != null) {
455-
extTemp = sky.getTemperatureCelsius().floatValue();
470+
// The broadcast first. It is what fills the head unit's own status bar, so on this
471+
// car it is the source known to be producing a number, where the map service's
472+
// query never even bound.
473+
Float broadcast = headUnitWeather.temperatureC();
474+
if (TelemetryPayload.isPlausibleTemp(broadcast)) {
475+
extTemp = broadcast;
476+
tempSource = "bcast:" + headUnitWeather.diagnostic();
477+
} else {
478+
sky = weatherAt(loc);
479+
if (sky != null && sky.getTemperatureCelsius() != null) {
480+
extTemp = sky.getTemperatureCelsius().floatValue();
481+
tempSource = "map:" + sky.getCity();
482+
} else {
483+
// Neither answered. Which one failed, and how, is the difference between
484+
// "the broadcast never arrives" and "it arrives in a shape this cannot
485+
// read" — and nothing on the ABRP side tells them apart.
486+
tempSource = "none bcast=" + headUnitWeather.diagnostic()
487+
+ " map=" + (SaicWeather.INSTANCE.isAvailable() ? "bound" : "unbound");
488+
}
456489
}
457490
}
458491

@@ -486,7 +519,7 @@ private void doUpload() {
486519
}
487520

488521
String tlmJson = tlm.build();
489-
String summary = TelemetryPayload.summarize(tlmJson);
522+
String summary = TelemetryPayload.summarize(tlmJson) + " [temp " + tempSource + "]";
490523
if (loc != null) {
491524
summary += " (fix " + locationAgeMs(loc) / 1000 + "s old"
492525
+ " +/-" + (loc.hasAccuracy() ? Math.round(loc.getAccuracy()) + "m" : "?")
@@ -505,6 +538,27 @@ private void doUpload() {
505538
sendToAbrp(apiKey, token, tlmJson, summary, soc, speedKmh, carUp);
506539
}
507540

541+
/**
542+
* Listens for the head unit's weather updates.
543+
*
544+
* Exported on purpose: the broadcast comes from another application, and a receiver
545+
* declared not-exported would never hear it. The flag is required from API 33 and does
546+
* not exist before it — the car this runs on is API 28 — so the registration goes
547+
* through ContextCompat, which picks the right overload for the platform underneath.
548+
*/
549+
private void registerWeatherReceiver() {
550+
try {
551+
androidx.core.content.ContextCompat.registerReceiver(
552+
this, weatherReceiver, new IntentFilter(HeadUnitWeather.ACTION),
553+
androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
554+
weatherReceiverRegistered = true;
555+
} catch (Exception e) {
556+
// A car whose weather app broadcasts nothing is a car without ext_temp, not a
557+
// reason to lose the upload service.
558+
Log.w(TAG, "Weather broadcast unavailable: " + e.getMessage());
559+
}
560+
}
561+
508562
/**
509563
* Current conditions where the car is, or null when nothing can answer.
510564
*
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package com.evsuite.abrp;
2+
3+
import android.content.Intent;
4+
import android.os.Bundle;
5+
6+
import org.json.JSONArray;
7+
import org.json.JSONObject;
8+
9+
/**
10+
* Outside temperature from the head unit's own weather app.
11+
*
12+
* The car does not implement ENV_OUTSIDE_TEMPERATURE, so there is no vehicle sensor to read,
13+
* and the vendor map service that answers a weather query by position never bound on this
14+
* firmware — {@code ext_temp} was simply absent from every upload. What demonstrably works is
15+
* the head unit's status bar, which shows a temperature all the time. It gets it from a
16+
* broadcast, and so does this.
17+
*
18+
* **Everything about the payload here is unverified.** The action and the extra name come from
19+
* reading the head unit, not from a published interface, and the shape inside is read by
20+
* searching rather than by field offsets: any key whose name mentions a temperature, at any
21+
* depth. That is deliberate. A parser written against a guessed layout fails silently and
22+
* looks exactly like the bug it was meant to fix, whereas a search either finds a number or
23+
* reports what it was given — see {@link #diagnostic()}, which reaches the app's own log.
24+
*
25+
* Stays in this app rather than in EVHardware for the same reason: the shared library carries
26+
* vendor knowledge that has been confirmed on a car. Once a driver's log shows this reading a
27+
* real broadcast, it belongs there instead, next to SaicWeather.
28+
*/
29+
final class HeadUnitWeather {
30+
31+
/** The head unit's weather app announces each update with this. */
32+
static final String ACTION = "com.saicmotor.weather";
33+
34+
/** Enough of an unreadable payload to recognise its shape, short enough for a log line. */
35+
private static final int RAW_SAMPLE_CHARS = 200;
36+
37+
/** How deep the search descends. A weather payload is nested, not deeply nested. */
38+
private static final int MAX_DEPTH = 6;
39+
40+
private volatile Float temperatureC;
41+
/** The place the head unit named — what ABRP's address is checked against. */
42+
private volatile String place = "";
43+
/** Why the last broadcast produced nothing, for the log. Null once one produced something. */
44+
private volatile String problem = "no broadcast yet";
45+
private volatile long receivedMs = 0L;
46+
47+
/** Outside temperature in °C, or null while no broadcast has carried one. */
48+
Float temperatureC() {
49+
return temperatureC;
50+
}
51+
52+
String place() {
53+
return place;
54+
}
55+
56+
long receivedMs() {
57+
return receivedMs;
58+
}
59+
60+
/**
61+
* One short token for the upload log: the place a reading came from, or the reason there
62+
* is none. "no broadcast yet" and "no temperature in {...}" call for different fixes and
63+
* are indistinguishable from the ABRP side.
64+
*/
65+
String diagnostic() {
66+
if (temperatureC != null) return place.isEmpty() ? "ok" : place;
67+
return problem;
68+
}
69+
70+
/** Reads whatever the head unit just broadcast. Never throws: this runs in a receiver. */
71+
void accept(Intent intent) {
72+
acceptPayload(payloadOf(intent));
73+
}
74+
75+
/**
76+
* The half of {@link #accept} that has no Android in it, so the search below is testable
77+
* against the payloads it might be handed rather than only against the one it expects.
78+
*/
79+
void acceptPayload(String payload) {
80+
try {
81+
if (payload == null) {
82+
problem = "broadcast carried no JSON";
83+
return;
84+
}
85+
JSONObject json = new JSONObject(payload);
86+
Float celsius = toCelsius(findNumber(json, "temp", 0), findUnit(json, 0));
87+
if (celsius == null) {
88+
problem = "no temperature in " + truncate(payload);
89+
return;
90+
}
91+
temperatureC = celsius;
92+
String named = findText(json, 0);
93+
place = named == null ? "" : named;
94+
receivedMs = System.currentTimeMillis();
95+
problem = null;
96+
} catch (Exception e) {
97+
// A payload that is not the JSON this expects is a finding, not a crash.
98+
problem = e.getClass().getSimpleName() + " reading broadcast";
99+
}
100+
}
101+
102+
/**
103+
* The JSON the broadcast carries. Named extras first, then any string extra that looks
104+
* like an object — the extra's name is as unverified as the rest of the payload, and a
105+
* renamed key is not a reason to give up on a broadcast that is otherwise readable.
106+
*/
107+
private static String payloadOf(Intent intent) {
108+
Bundle extras = intent.getExtras();
109+
if (extras == null) return null;
110+
String named = asJson(extras.get("weather"));
111+
if (named != null) return named;
112+
for (String key : extras.keySet()) {
113+
String candidate = asJson(extras.get(key));
114+
if (candidate != null) return candidate;
115+
}
116+
return null;
117+
}
118+
119+
private static String asJson(Object value) {
120+
if (!(value instanceof String)) return null;
121+
String text = ((String) value).trim();
122+
return text.startsWith("{") ? text : null;
123+
}
124+
125+
/**
126+
* The first number under a key whose name mentions [needle], at any depth.
127+
*
128+
* A search rather than a path: the payload's own field names are the only thing here that
129+
* is self-describing, and they survive a layout change that a field order would not.
130+
*/
131+
private static Double findNumber(Object node, String needle, int depth) {
132+
if (depth > MAX_DEPTH) return null;
133+
if (node instanceof JSONObject) {
134+
JSONObject object = (JSONObject) node;
135+
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
136+
String key = keys.next();
137+
Object value = object.opt(key);
138+
if (key.toLowerCase(java.util.Locale.US).contains(needle)) {
139+
Double number = asNumber(value);
140+
if (number != null) return number;
141+
}
142+
Double nested = findNumber(value, needle, depth + 1);
143+
if (nested != null) return nested;
144+
}
145+
} else if (node instanceof JSONArray) {
146+
JSONArray array = (JSONArray) node;
147+
for (int i = 0; i < array.length(); i++) {
148+
Double nested = findNumber(array.opt(i), needle, depth + 1);
149+
if (nested != null) return nested;
150+
}
151+
}
152+
return null;
153+
}
154+
155+
/** Numbers arrive as numbers or as strings depending on the producer; both count. */
156+
private static Double asNumber(Object value) {
157+
if (value instanceof Number) return ((Number) value).doubleValue();
158+
if (value instanceof String) {
159+
try {
160+
return Double.valueOf(((String) value).trim());
161+
} catch (NumberFormatException ignored) {
162+
return null;
163+
}
164+
}
165+
return null;
166+
}
167+
168+
/** The unit the payload names, if it names one at all. */
169+
private static String findUnit(Object node, int depth) {
170+
if (depth > MAX_DEPTH) return null;
171+
if (node instanceof JSONObject) {
172+
JSONObject object = (JSONObject) node;
173+
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
174+
String key = keys.next();
175+
Object value = object.opt(key);
176+
if (key.toLowerCase(java.util.Locale.US).contains("unit")
177+
&& value instanceof String) {
178+
return (String) value;
179+
}
180+
String nested = findUnit(value, depth + 1);
181+
if (nested != null) return nested;
182+
}
183+
}
184+
return null;
185+
}
186+
187+
/** The place the reading is for — whatever the payload calls it. */
188+
private static String findText(Object node, int depth) {
189+
if (depth > MAX_DEPTH || !(node instanceof JSONObject)) return null;
190+
JSONObject object = (JSONObject) node;
191+
for (java.util.Iterator<String> keys = object.keys(); keys.hasNext(); ) {
192+
String key = keys.next();
193+
String lower = key.toLowerCase(java.util.Locale.US);
194+
Object value = object.opt(key);
195+
if ((lower.contains("address") || lower.contains("city") || lower.contains("poi"))
196+
&& value instanceof String && !((String) value).trim().isEmpty()) {
197+
return ((String) value).trim();
198+
}
199+
String nested = findText(value, depth + 1);
200+
if (nested != null) return nested;
201+
}
202+
return null;
203+
}
204+
205+
/**
206+
* Celsius, or null when the payload named a unit this does not know.
207+
*
208+
* An unnamed unit is taken as Celsius: the head unit displays °C on this car, and the
209+
* value is sanity-checked by the caller anyway. A named Fahrenheit is converted, because
210+
* passing 81 °F through as 81 °C is worse than reporting nothing.
211+
*/
212+
private static Float toCelsius(Double value, String unit) {
213+
if (value == null) return null;
214+
String name = unit == null ? "" : unit.trim().toUpperCase(java.util.Locale.US);
215+
if (name.contains("F")) return (float) ((value - 32.0) * 5.0 / 9.0);
216+
return value.floatValue();
217+
}
218+
219+
private static String truncate(String payload) {
220+
return payload.length() <= RAW_SAMPLE_CHARS
221+
? payload
222+
: payload.substring(0, RAW_SAMPLE_CHARS) + "…";
223+
}
224+
}

0 commit comments

Comments
 (0)