App 2.3.0: Server tab, live telemetry to the car server
- New Server tab: server URL, ingest token and installation id, with
Save, Test connection (health, then a read-only token check) and the
config-file import that upstream removed (app folder / USB stick first,
document picker as fallback). Settings are kept encrypted and read on
every use, so the service picks up a change without a restart. Each
installation generates its own id; the inherited upstream UUID and
the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
UploadCadence, UploadSettings, GPS) and sent in batches to
POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
ext_temp, voltage and current. Unsent samples queue in memory (about
8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
for the deleted AbrpApi and HeadUnitWeather removed; new tests for
ServerConfig and TelemetryQueue. Lint passes.
Not yet tried on the car.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,50 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* [T-912] Credentials must reach ABRP in the request body. A URL is logged by
|
||||
* proxies, CDNs and crash reporters; a body is not.
|
||||
*/
|
||||
public class AbrpApiTest {
|
||||
|
||||
@Test
|
||||
public void endpointUrlCarriesNoCredentials() {
|
||||
assertFalse(AbrpApi.API_URL.contains("?"));
|
||||
assertFalse(AbrpApi.API_URL.contains("api_key"));
|
||||
assertFalse(AbrpApi.API_URL.contains("token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void endpointIsHttps() {
|
||||
assertTrue(AbrpApi.API_URL.startsWith("https://"));
|
||||
assertTrue(AbrpApi.VERIFY_URL.startsWith("https://"));
|
||||
}
|
||||
|
||||
/** [T-913] The Test button must not be able to write telemetry. */
|
||||
@Test
|
||||
public void credentialCheckDoesNotTargetTheTelemetryWriteEndpoint() {
|
||||
assertFalse("le test de connexion ne doit jamais viser /tlm/send",
|
||||
AbrpApi.VERIFY_URL.contains("/tlm/send"));
|
||||
assertTrue(AbrpApi.VERIFY_URL.contains("get_next_charge"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyCarriesAllThreeParameters() throws Exception {
|
||||
String body = AbrpApi.formBody("KEY", "TOK", "{\"utc\":1}");
|
||||
assertTrue(body.contains("api_key=KEY"));
|
||||
assertTrue(body.contains("token=TOK"));
|
||||
assertTrue(body.contains("tlm="));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyIsFormEncoded() throws Exception {
|
||||
// A token with URL-significant characters must not break the body layout.
|
||||
String body = AbrpApi.formBody("a+b/c", "d&e=f", "{\"utc\":1}");
|
||||
assertEquals("api_key=a%2Bb%2Fc&token=d%26e%3Df&tlm=%7B%22utc%22%3A1%7D", body);
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,37 @@ import static org.junit.Assert.assertTrue;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* ABRP config file parsing — pure JVM, no Android needed.
|
||||
* Config file parsing — pure JVM, no Android needed.
|
||||
*
|
||||
* The file exists so a user does not have to type a long api_key and token on the car's
|
||||
* The file exists so a user does not have to type a long server URL and token on the car's
|
||||
* on-screen keyboard, so the parser must be forgiving about whitespace, case and comments
|
||||
* while never inventing a value the file did not set.
|
||||
*/
|
||||
public class ConfigImportTest {
|
||||
|
||||
@Test
|
||||
public void parsesCredentials() {
|
||||
ConfigImport c = ConfigImport.parse("api_key = abc123\ntoken = def456\n");
|
||||
assertEquals("abc123", c.apiKey);
|
||||
public void parsesServerSettings() {
|
||||
ConfigImport c = ConfigImport.parse("server_url = https://car.example.com\ntoken = def456\n"
|
||||
+ "installation_id = 6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b\n");
|
||||
assertEquals("https://car.example.com", c.serverUrl);
|
||||
assertEquals("def456", c.token);
|
||||
assertEquals("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", c.installationId);
|
||||
assertFalse(c.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresCommentsBlankLinesAndCase() {
|
||||
ConfigImport c = ConfigImport.parse(
|
||||
"# my config\n\n API_KEY = key \n# token below\nTOKEN=tok\n");
|
||||
assertEquals("key", c.apiKey);
|
||||
"# my config\n\n SERVER_URL = https://x.example \n# token below\nTOKEN=tok\n");
|
||||
assertEquals("https://x.example", c.serverUrl);
|
||||
assertEquals("tok", c.token);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void absentKeysStayNullSoNothingIsWiped() {
|
||||
ConfigImport c = ConfigImport.parse("token = only-token\n");
|
||||
assertNull(c.apiKey);
|
||||
assertNull(c.serverUrl);
|
||||
assertNull(c.installationId);
|
||||
assertEquals("only-token", c.token);
|
||||
assertNull(c.intervalSec);
|
||||
assertNull(c.boostLowSoc);
|
||||
@@ -80,8 +83,8 @@ public class ConfigImportTest {
|
||||
|
||||
@Test
|
||||
public void aliasKeysAreAccepted() {
|
||||
ConfigImport c = ConfigImport.parse("apikey = k\nuser_token = t\n");
|
||||
assertEquals("k", c.apiKey);
|
||||
ConfigImport c = ConfigImport.parse("url = https://k.example\ningest_token = t\n");
|
||||
assertEquals("https://k.example", c.serverUrl);
|
||||
assertEquals("t", c.token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* The broadcast parser, on the JVM.
|
||||
*
|
||||
* What is under test is not the vendor's payload — that shape is unverified and may well be
|
||||
* something else on the car. It is that the parser finds a temperature wherever the payload
|
||||
* puts it, refuses to invent one, and says what it was given when it fails, because the
|
||||
* diagnostic is the only thing that reaches a driver with no adb.
|
||||
*/
|
||||
public class HeadUnitWeatherTest {
|
||||
|
||||
@Test
|
||||
public void readsATemperatureAndThePlaceItIsFor() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload((
|
||||
"{\"code\":0,\"weather\":{\"temperature\":27,\"unit\":\"C\","
|
||||
+ "\"address\":\"Toulouse\",\"conditionType\":1}}"));
|
||||
assertEquals(27f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Toulouse", weather.place());
|
||||
// The diagnostic carries the reading's age: a broadcast arrives when the weather app
|
||||
// refreshes, so "where from" without "how long ago" does not say whether to trust it.
|
||||
assertEquals("Toulouse/0min", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findsTheValueWhereverThePayloadNests_it() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload((
|
||||
"{\"data\":{\"current\":[{\"tempValue\":\"18.5\",\"tempUnit\":\"C\"}]}}"));
|
||||
assertEquals(18.5f, weather.temperatureC(), 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convertsAFahrenheitPayload() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"temperature\":81,\"temperatureUnit\":\"F\"}"));
|
||||
assertEquals(27.2f, weather.temperatureC(), 0.1f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anUnnamedUnitIsTakenAsCelsius() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"temp\":21}"));
|
||||
assertEquals(21f, weather.temperatureC(), 0.01f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aPayloadWithoutATemperatureReportsWhatItWasGiven() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{\"conditionType\":3,\"address\":\"Toulouse\"}"));
|
||||
assertNull(weather.temperatureC());
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().startsWith("no temperature in {"));
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().contains("conditionType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readsThePlaceFromACityKeyToo() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload("{\"temp\":14,\"city\":\"Auzeville\"}");
|
||||
assertEquals(14f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Auzeville", weather.place());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBroadcastWithNothingReadableIsReportedNotGuessed() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(null);
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("broadcast carried no JSON", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void beforeAnyBroadcastTheDiagnosticSaysSo() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("no broadcast yet", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedJsonDoesNotEscapeTheReceiver() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.acceptPayload(("{not json at all"));
|
||||
assertNull(weather.temperatureC());
|
||||
assertTrue(weather.diagnostic(), weather.diagnostic().contains("reading broadcast"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aRestoredReadingIsReportedWithItsAge() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.restore(19.5f, "Toulouse", System.currentTimeMillis() - 45 * 60 * 1000L);
|
||||
assertEquals(19.5f, weather.temperatureC(), 0.01f);
|
||||
assertEquals("Toulouse/45min", weather.diagnostic());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anAbsentStickyBroadcastIsNotMistakenForAFailedParse() {
|
||||
HeadUnitWeather weather = new HeadUnitWeather();
|
||||
weather.accept(null);
|
||||
assertNull(weather.temperatureC());
|
||||
assertEquals("none held, none sent yet", weather.diagnostic());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** What the Server tab and the config import accept — pure JVM. */
|
||||
public class ServerConfigTest {
|
||||
|
||||
@Test
|
||||
public void urlIsTrimmedAndLosesTrailingSlashes() {
|
||||
assertEquals("https://car.example.com", ServerConfig.normalizeUrl(" https://car.example.com// "));
|
||||
assertEquals("https://car.example.com/base", ServerConfig.normalizeUrl("https://car.example.com/base/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plainHttpAndJunkAreRefused() {
|
||||
// The token travels in a header; over plain http anyone on the hotspot could read it.
|
||||
assertNull(ServerConfig.normalizeUrl("http://car.example.com"));
|
||||
assertNull(ServerConfig.normalizeUrl("car.example.com"));
|
||||
assertNull(ServerConfig.normalizeUrl("https://"));
|
||||
assertNull(ServerConfig.normalizeUrl("https:///path"));
|
||||
assertNull(ServerConfig.normalizeUrl("https://user@car.example.com"));
|
||||
assertNull(ServerConfig.normalizeUrl("https://car.example.com/?x=1"));
|
||||
assertNull(ServerConfig.normalizeUrl(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tokenMustMeetTheServersMinimum() {
|
||||
assertTrue(ServerConfig.isValidToken("0123456789abcdef01234567"));
|
||||
assertFalse(ServerConfig.isValidToken("too-short"));
|
||||
assertFalse(ServerConfig.isValidToken("0123456789abcdef 01234567"));
|
||||
assertFalse(ServerConfig.isValidToken(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void installationIdMustBeAUuidTheServerAccepts() {
|
||||
assertTrue(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"));
|
||||
assertTrue(ServerConfig.isValidInstallationId(java.util.UUID.randomUUID().toString()));
|
||||
assertFalse(ServerConfig.isValidInstallationId("6f1a2b3c-4d5e-0f60-8a9b-0c1d2e3f4a5b")); // version 0
|
||||
assertFalse(ServerConfig.isValidInstallationId("not-a-uuid"));
|
||||
assertFalse(ServerConfig.isValidInstallationId(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The offline buffer for live samples — pure JVM. */
|
||||
public class TelemetryQueueTest {
|
||||
|
||||
@Test
|
||||
public void batchesOldestFirstAndKeepsThemUntilConfirmed() {
|
||||
TelemetryQueue queue = new TelemetryQueue();
|
||||
queue.add("a");
|
||||
queue.add("b");
|
||||
assertEquals(List.of("a", "b"), queue.peekBatch());
|
||||
// Not confirmed yet: a failed request must not lose them.
|
||||
assertEquals(2, queue.size());
|
||||
queue.remove(2);
|
||||
assertEquals(0, queue.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aBatchNeverExceedsWhatTheServerAccepts() {
|
||||
TelemetryQueue queue = new TelemetryQueue();
|
||||
for (int i = 0; i < 1200; i++) queue.add(String.valueOf(i));
|
||||
List<String> batch = queue.peekBatch();
|
||||
assertEquals(TelemetryQueue.MAX_BATCH, batch.size());
|
||||
assertEquals("0", batch.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void whenFullTheOldestSamplesAreDropped() {
|
||||
TelemetryQueue queue = new TelemetryQueue(3);
|
||||
for (String s : new String[]{"a", "b", "c", "d", "e"}) queue.add(s);
|
||||
assertEquals(List.of("c", "d", "e"), queue.peekBatch());
|
||||
assertEquals(2, queue.dropped());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user