App 2.12.0, server 2.5.0: enter settings on the phone
The ABRP credentials are long to type on the car. "Enter on phone" (Live data tab, and Settings next to the charging price) shows a QR code; it opens a form on the server where the phone sends the ABRP API key and user token, charging price and usable capacity, confirmed with the approval password. The car polls with its own token and collects the values once, then saves them through the same checks as the fields on screen. Same device-flow shape as pairing. The server keeps the values in memory only, at most ten minutes, hands them out once with no-store, and never logs them or puts them back into the page; a paired token only reaches its own installation's request. Pairing's QR dialog is now shared (PhoneQrDialog), and the phone pages share one stylesheet. The config file also takes charging_price and usable_kwh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -118,6 +118,15 @@ pointed at this repository's releases (see `SPECS.md`).
|
|||||||
4. On the **Live data** tab, turn live data on. The service starts with the car from then on.
|
4. On the **Live data** tab, turn live data on. The service starts with the car from then on.
|
||||||
5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP.
|
5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP.
|
||||||
|
|
||||||
|
### On your phone
|
||||||
|
|
||||||
|
Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a
|
||||||
|
QR code. It opens a form on your server where you type the ABRP API key and user token, the
|
||||||
|
charging price and the usable capacity, and send them with the approval password. The car
|
||||||
|
collects them within a few seconds and saves them through the same checks as the fields on
|
||||||
|
screen. The server only passes them on: they are held in memory until the car has them (at most
|
||||||
|
ten minutes), never stored or logged.
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
|
|
||||||
Instead of typing, put the settings in a text file and tap **Import file** on the Server tab.
|
Instead of typing, put the settings in a text file and tap **Import file** on the Server tab.
|
||||||
@@ -140,6 +149,8 @@ boost_low_soc = true
|
|||||||
low_soc_percent = 20
|
low_soc_percent = 20
|
||||||
abrp_api_key = your ABRP API key
|
abrp_api_key = your ABRP API key
|
||||||
abrp_token = your ABRP user token
|
abrp_token = your ABRP user token
|
||||||
|
charging_price = 0.39 # €/kWh
|
||||||
|
usable_kwh = 50.8 # usable battery capacity, for the range estimates
|
||||||
```
|
```
|
||||||
|
|
||||||
After a successful import the app deletes the file, since it holds the token; credentials live
|
After a successful import the app deletes the file, since it holds the token; credentials live
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
|
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
|
||||||
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
|
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
|
||||||
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
|
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
|
||||||
|
- 2026-10-18 — App 2.12.0, server 2.5.0: settings entered on a phone. "Enter on phone" shows a QR
|
||||||
|
code; the phone form on the server (approval password) takes the ABRP credentials, charging
|
||||||
|
price and usable capacity, and the car collects them once (RFC 8628-style, like pairing; values
|
||||||
|
in server memory only, at most 10 minutes). The config file also takes `charging_price` and
|
||||||
|
`usable_kwh`. The QR dialog is shared with pairing (`PhoneQrDialog`). Tested end to end with
|
||||||
|
curl against the server image and InfluxDB; the app side on the emulator only up to the dialog.
|
||||||
- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the
|
- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the
|
||||||
server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no
|
server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no
|
||||||
longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working.
|
longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working.
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 74
|
versionCode 75
|
||||||
versionName "2.11.1"
|
versionName "2.12.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ package com.evsuite.abrp;
|
|||||||
* low_soc_percent = 20
|
* low_soc_percent = 20
|
||||||
* abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly)
|
* abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly)
|
||||||
* abrp_token = your-abrp-user-token
|
* abrp_token = your-abrp-user-token
|
||||||
|
* charging_price = 0.39 (€/kWh)
|
||||||
|
* usable_kwh = 50.8 (usable battery capacity, for the range estimates)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* Usually only server_url and token are needed. installation_id is for a reinstall: it puts
|
* Usually only server_url and token are needed. installation_id is for a reinstall: it puts
|
||||||
@@ -33,9 +35,12 @@ final class ConfigImport {
|
|||||||
final Integer lowSocPercent;
|
final Integer lowSocPercent;
|
||||||
final String abrpApiKey;
|
final String abrpApiKey;
|
||||||
final String abrpToken;
|
final String abrpToken;
|
||||||
|
final Double chargingPrice;
|
||||||
|
final Double usableKwh;
|
||||||
|
|
||||||
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
|
private ConfigImport(String serverUrl, String token, String installationId, Integer intervalSec,
|
||||||
Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken) {
|
Boolean boostLowSoc, Integer lowSocPercent, String abrpApiKey, String abrpToken,
|
||||||
|
Double chargingPrice, Double usableKwh) {
|
||||||
this.serverUrl = serverUrl;
|
this.serverUrl = serverUrl;
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.installationId = installationId;
|
this.installationId = installationId;
|
||||||
@@ -44,18 +49,22 @@ final class ConfigImport {
|
|||||||
this.lowSocPercent = lowSocPercent;
|
this.lowSocPercent = lowSocPercent;
|
||||||
this.abrpApiKey = abrpApiKey;
|
this.abrpApiKey = abrpApiKey;
|
||||||
this.abrpToken = abrpToken;
|
this.abrpToken = abrpToken;
|
||||||
|
this.chargingPrice = chargingPrice;
|
||||||
|
this.usableKwh = usableKwh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
||||||
boolean isEmpty() {
|
boolean isEmpty() {
|
||||||
return serverUrl == null && token == null && installationId == null && intervalSec == null
|
return serverUrl == null && token == null && installationId == null && intervalSec == null
|
||||||
&& boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null;
|
&& boostLowSoc == null && lowSocPercent == null && abrpApiKey == null && abrpToken == null
|
||||||
|
&& chargingPrice == null && usableKwh == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static ConfigImport parse(String text) {
|
static ConfigImport parse(String text) {
|
||||||
String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null;
|
String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null;
|
||||||
Integer intervalSec = null, lowSocPercent = null;
|
Integer intervalSec = null, lowSocPercent = null;
|
||||||
Boolean boostLowSoc = null;
|
Boolean boostLowSoc = null;
|
||||||
|
Double chargingPrice = null, usableKwh = null;
|
||||||
|
|
||||||
for (String line : text.split("\n", -1)) {
|
for (String line : text.split("\n", -1)) {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
@@ -98,6 +107,14 @@ final class ConfigImport {
|
|||||||
case "abrp_user_token":
|
case "abrp_user_token":
|
||||||
abrpToken = value;
|
abrpToken = value;
|
||||||
break;
|
break;
|
||||||
|
case "charging_price":
|
||||||
|
case "price_per_kwh":
|
||||||
|
chargingPrice = parseDecimal(value);
|
||||||
|
break;
|
||||||
|
case "usable_kwh":
|
||||||
|
case "usable_capacity_kwh":
|
||||||
|
usableKwh = parseDecimal(value);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown keys are ignored rather than rejected, so a file can carry
|
// Unknown keys are ignored rather than rejected, so a file can carry
|
||||||
// comments or future keys without breaking an older build.
|
// comments or future keys without breaking an older build.
|
||||||
@@ -105,7 +122,7 @@ final class ConfigImport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent,
|
return new ConfigImport(serverUrl, token, installationId, intervalSec, boostLowSoc, lowSocPercent,
|
||||||
abrpApiKey, abrpToken);
|
abrpApiKey, abrpToken, chargingPrice, usableKwh);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
|
/** Snaps to the nearest offered choice: the UI spinner has no free-form interval. */
|
||||||
@@ -132,6 +149,19 @@ final class ConfigImport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decimal with a dot or a comma; null when it is not a number. Ranges are checked where
|
||||||
|
* the value is saved, the same as for the fields on screen.
|
||||||
|
*/
|
||||||
|
private static Double parseDecimal(String value) {
|
||||||
|
try {
|
||||||
|
double d = Double.parseDouble(value.replace(',', '.'));
|
||||||
|
return Double.isFinite(d) ? d : null;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Boolean parseBool(String value) {
|
private static Boolean parseBool(String value) {
|
||||||
value = value.toLowerCase(java.util.Locale.US);
|
value = value.toLowerCase(java.util.Locale.US);
|
||||||
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
|
if (value.equals("true") || value.equals("on") || value.equals("1")) return Boolean.TRUE;
|
||||||
|
|||||||
@@ -531,71 +531,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
String installationId = ServerConfig.isValidInstallationId(typedId)
|
String installationId = ServerConfig.isValidInstallationId(typedId)
|
||||||
? typedId : ServerConfig.get(this).installationId;
|
? typedId : ServerConfig.get(this).installationId;
|
||||||
|
|
||||||
float density = getResources().getDisplayMetrics().density;
|
PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting);
|
||||||
int pad = (int) (24 * density);
|
final boolean[] cancelled = ui.cancelled;
|
||||||
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
|
|
||||||
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
|
|
||||||
// nothing can be clipped on any screen.
|
|
||||||
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
||||||
boolean wide = metrics.widthPixels > metrics.heightPixels;
|
|
||||||
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
|
||||||
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
|
|
||||||
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
|
|
||||||
panel.setPadding(pad, pad, pad, pad);
|
|
||||||
android.widget.LinearLayout column = new android.widget.LinearLayout(this);
|
|
||||||
column.setOrientation(android.widget.LinearLayout.VERTICAL);
|
|
||||||
TextView instructions = new TextView(this);
|
|
||||||
instructions.setTextAppearance(R.style.Text_EV_Secondary);
|
|
||||||
ImageView qr = new ImageView(this);
|
|
||||||
qr.setContentDescription(getString(R.string.pair_qr_description));
|
|
||||||
int qrSize = (int) (240 * density);
|
|
||||||
TextView code = new TextView(this);
|
|
||||||
code.setTextAppearance(R.style.Text_EV_Title);
|
|
||||||
code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD);
|
|
||||||
code.setTextSize(36);
|
|
||||||
code.setLetterSpacing(0.15f);
|
|
||||||
code.setTextColor(getColor(R.color.ev_cyan));
|
|
||||||
TextView status = new TextView(this);
|
|
||||||
status.setTextAppearance(R.style.Text_EV_Body);
|
|
||||||
status.setText(R.string.pair_starting);
|
|
||||||
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
|
|
||||||
android.widget.LinearLayout.LayoutParams columnParams;
|
|
||||||
if (wide) {
|
|
||||||
qrParams.rightMargin = pad;
|
|
||||||
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
||||||
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
|
||||||
panel.addView(qr, qrParams);
|
|
||||||
panel.addView(column, columnParams);
|
|
||||||
column.addView(instructions);
|
|
||||||
} else {
|
|
||||||
qrParams.topMargin = pad / 2;
|
|
||||||
qrParams.bottomMargin = pad / 2;
|
|
||||||
columnParams = new android.widget.LinearLayout.LayoutParams(
|
|
||||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
||||||
panel.addView(instructions);
|
|
||||||
panel.addView(qr, qrParams);
|
|
||||||
panel.addView(column, columnParams);
|
|
||||||
}
|
|
||||||
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
|
|
||||||
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
|
||||||
codeParams.topMargin = pad / 2;
|
|
||||||
codeParams.bottomMargin = pad / 2;
|
|
||||||
column.addView(code, codeParams);
|
|
||||||
column.addView(status);
|
|
||||||
android.widget.ScrollView scroll = new android.widget.ScrollView(this);
|
|
||||||
scroll.addView(panel);
|
|
||||||
|
|
||||||
final boolean[] cancelled = {false};
|
|
||||||
androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this)
|
|
||||||
.setTitle(R.string.pair_title)
|
|
||||||
.setView(scroll)
|
|
||||||
.setNegativeButton(R.string.pair_cancel, null)
|
|
||||||
.create();
|
|
||||||
// Shown only once the server has answered, so a failure leaves no empty QR frame.
|
|
||||||
qr.setVisibility(View.GONE);
|
|
||||||
code.setVisibility(View.GONE);
|
|
||||||
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
|
||||||
dialog.show();
|
|
||||||
|
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
String outcome;
|
String outcome;
|
||||||
@@ -603,18 +540,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
try {
|
try {
|
||||||
PairingClient.Request request = PairingClient.start(url, installationId);
|
PairingClient.Request request = PairingClient.start(url, installationId);
|
||||||
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.pair_scan, R.string.pair_waiting));
|
||||||
// The code is in the sentence as well as below it, so it is readable
|
|
||||||
// wherever the text is.
|
|
||||||
instructions.setText(getString(R.string.pair_scan, request.verificationUri, request.userCode));
|
|
||||||
if (bitmap != null) {
|
|
||||||
qr.setImageBitmap(bitmap);
|
|
||||||
qr.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
code.setText(request.userCode);
|
|
||||||
code.setVisibility(View.VISIBLE);
|
|
||||||
status.setText(R.string.pair_waiting);
|
|
||||||
});
|
|
||||||
PairingClient.PollResult result = null;
|
PairingClient.PollResult result = null;
|
||||||
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
||||||
Thread.sleep(request.intervalMs);
|
Thread.sleep(request.intervalMs);
|
||||||
@@ -655,21 +581,199 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
showServerConfig();
|
showServerConfig();
|
||||||
serverTokenLayout.setError(null);
|
serverTokenLayout.setError(null);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
if (!cancelled[0]) dialog.dismiss();
|
if (!cancelled[0]) ui.dialog.dismiss();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cancelled[0]) return;
|
|
||||||
// Keep the dialog up with the reason, instead of closing it on a failure the
|
// Keep the dialog up with the reason, instead of closing it on a failure the
|
||||||
// driver would otherwise only see as a flicker.
|
// driver would otherwise only see as a flicker.
|
||||||
|
if (!cancelled[0]) ui.finish(message, c);
|
||||||
|
});
|
||||||
|
}, "server-pairing").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings typed on a phone instead of the car's keyboard: the server opens a request for
|
||||||
|
* this car, the dialog shows it as a QR code, and the values the phone sends (ABRP
|
||||||
|
* credentials, charging price, usable capacity) are applied through the same checks as the
|
||||||
|
* fields here. Needs the server set up, since the request is made with this car's token.
|
||||||
|
*/
|
||||||
|
private void startPhoneSettings() {
|
||||||
|
ServerConfig config = ServerConfig.get(this);
|
||||||
|
PhoneQrDialog ui = new PhoneQrDialog(R.string.phone_settings_title, R.string.pair_starting);
|
||||||
|
if (!config.isConfigured()) {
|
||||||
|
ui.finish(getString(R.string.phone_settings_needs_server), COLOR_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Thread(() -> {
|
||||||
|
String outcome;
|
||||||
|
int color = COLOR_ERROR;
|
||||||
|
PhoneSettingsClient.Settings received = null;
|
||||||
|
try {
|
||||||
|
PairingClient.Request request = PhoneSettingsClient.start(config);
|
||||||
|
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
||||||
|
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.phone_settings_scan,
|
||||||
|
R.string.phone_settings_waiting));
|
||||||
|
while (!ui.cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
||||||
|
Thread.sleep(request.intervalMs);
|
||||||
|
if (ui.cancelled[0]) return;
|
||||||
|
try {
|
||||||
|
received = PhoneSettingsClient.poll(config, request.deviceCode);
|
||||||
|
} catch (java.io.IOException e) {
|
||||||
|
continue; // a dropped connection mid-wait is not a failure
|
||||||
|
}
|
||||||
|
if (received != null) break;
|
||||||
|
}
|
||||||
|
if (ui.cancelled[0]) return;
|
||||||
|
outcome = received == null ? getString(R.string.pair_expired_settings) : null;
|
||||||
|
} catch (PairingClient.NotEnabledException e) {
|
||||||
|
outcome = getString(R.string.phone_settings_not_enabled);
|
||||||
|
} catch (PairingClient.HttpStatusException e) {
|
||||||
|
outcome = e.status == 410 ? getString(R.string.pair_expired_settings)
|
||||||
|
: getString(R.string.err_http, e.status);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
return;
|
||||||
|
} catch (Exception e) {
|
||||||
|
outcome = describeNetworkError(e, config.url);
|
||||||
|
}
|
||||||
|
final PhoneSettingsClient.Settings settings = received;
|
||||||
|
final String failure = outcome;
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (ui.cancelled[0]) return;
|
||||||
|
if (settings == null) {
|
||||||
|
ui.finish(failure, COLOR_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String applied = applyPhoneSettings(settings);
|
||||||
|
ui.finish(applied, applied.equals(getString(R.string.phone_settings_applied)) ? COLOR_OK : COLOR_ERROR);
|
||||||
|
});
|
||||||
|
}, "phone-settings").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts what the phone sent into the fields and saves each through its own Save, so the
|
||||||
|
* same limits apply as when typed here. A credential sent alone is paired with the one
|
||||||
|
* already stored. Returns the message for the dialog. UI thread.
|
||||||
|
*/
|
||||||
|
private String applyPhoneSettings(PhoneSettingsClient.Settings s) {
|
||||||
|
boolean ok = true;
|
||||||
|
if (s.abrpApiKey != null || s.abrpToken != null) {
|
||||||
|
AbrpConfig current = AbrpConfig.get(this);
|
||||||
|
ok &= saveAbrpConfig(s.abrpApiKey != null ? s.abrpApiKey : current.apiKey,
|
||||||
|
s.abrpToken != null ? s.abrpToken : current.userToken);
|
||||||
|
}
|
||||||
|
if (s.pricePerKwh != null) {
|
||||||
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", s.pricePerKwh));
|
||||||
|
saveChargePrice();
|
||||||
|
ok &= chargePriceLayout.getError() == null;
|
||||||
|
}
|
||||||
|
if (s.usableKwh != null) {
|
||||||
|
usableCapacityInput.setText(String.format(java.util.Locale.US, "%.1f", s.usableKwh));
|
||||||
|
saveUsableCapacity();
|
||||||
|
ok &= usableCapacityLayout.getError() == null;
|
||||||
|
}
|
||||||
|
return getString(ok ? R.string.phone_settings_applied : R.string.phone_settings_partly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dialog of both phone flows (pairing, settings): a QR code beside the code to type
|
||||||
|
* and a status line. The QR code and code stay hidden until the server has answered.
|
||||||
|
*/
|
||||||
|
private final class PhoneQrDialog {
|
||||||
|
final TextView instructions, code, status;
|
||||||
|
final ImageView qr;
|
||||||
|
final androidx.appcompat.app.AlertDialog dialog;
|
||||||
|
/** Set when the driver closes the dialog; the polling thread then stops. */
|
||||||
|
final boolean[] cancelled = {false};
|
||||||
|
|
||||||
|
PhoneQrDialog(int title, int startingText) {
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
int pad = (int) (24 * density);
|
||||||
|
// On the head unit's wide, low screen the QR code sits left of the text: stacked, the
|
||||||
|
// code and status below a 240 dp QR code were cut off. Scrollable either way, so
|
||||||
|
// nothing can be clipped on any screen.
|
||||||
|
android.util.DisplayMetrics metrics = getResources().getDisplayMetrics();
|
||||||
|
boolean wide = metrics.widthPixels > metrics.heightPixels;
|
||||||
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(MainActivity.this);
|
||||||
|
panel.setOrientation(wide ? android.widget.LinearLayout.HORIZONTAL : android.widget.LinearLayout.VERTICAL);
|
||||||
|
panel.setGravity(wide ? android.view.Gravity.CENTER_VERTICAL : android.view.Gravity.CENTER_HORIZONTAL);
|
||||||
|
panel.setPadding(pad, pad, pad, pad);
|
||||||
|
android.widget.LinearLayout column = new android.widget.LinearLayout(MainActivity.this);
|
||||||
|
column.setOrientation(android.widget.LinearLayout.VERTICAL);
|
||||||
|
instructions = new TextView(MainActivity.this);
|
||||||
|
instructions.setTextAppearance(R.style.Text_EV_Secondary);
|
||||||
|
qr = new ImageView(MainActivity.this);
|
||||||
|
qr.setContentDescription(getString(R.string.pair_qr_description));
|
||||||
|
int qrSize = (int) (240 * density);
|
||||||
|
code = new TextView(MainActivity.this);
|
||||||
|
code.setTextAppearance(R.style.Text_EV_Title);
|
||||||
|
code.setTypeface(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD);
|
||||||
|
code.setTextSize(36);
|
||||||
|
code.setLetterSpacing(0.15f);
|
||||||
|
code.setTextColor(getColor(R.color.ev_cyan));
|
||||||
|
status = new TextView(MainActivity.this);
|
||||||
|
status.setTextAppearance(R.style.Text_EV_Body);
|
||||||
|
status.setText(startingText);
|
||||||
|
android.widget.LinearLayout.LayoutParams qrParams = new android.widget.LinearLayout.LayoutParams(qrSize, qrSize);
|
||||||
|
android.widget.LinearLayout.LayoutParams columnParams;
|
||||||
|
if (wide) {
|
||||||
|
qrParams.rightMargin = pad;
|
||||||
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
||||||
|
0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
|
||||||
|
panel.addView(qr, qrParams);
|
||||||
|
panel.addView(column, columnParams);
|
||||||
|
column.addView(instructions);
|
||||||
|
} else {
|
||||||
|
qrParams.topMargin = pad / 2;
|
||||||
|
qrParams.bottomMargin = pad / 2;
|
||||||
|
columnParams = new android.widget.LinearLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
panel.addView(instructions);
|
||||||
|
panel.addView(qr, qrParams);
|
||||||
|
panel.addView(column, columnParams);
|
||||||
|
}
|
||||||
|
android.widget.LinearLayout.LayoutParams codeParams = new android.widget.LinearLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT, android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
|
codeParams.topMargin = pad / 2;
|
||||||
|
codeParams.bottomMargin = pad / 2;
|
||||||
|
column.addView(code, codeParams);
|
||||||
|
column.addView(status);
|
||||||
|
android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
|
||||||
|
scroll.addView(panel);
|
||||||
|
|
||||||
|
dialog = new MaterialAlertDialogBuilder(MainActivity.this)
|
||||||
|
.setTitle(title)
|
||||||
|
.setView(scroll)
|
||||||
|
.setNegativeButton(R.string.pair_cancel, null)
|
||||||
|
.create();
|
||||||
|
// Shown only once the server has answered, so a failure leaves no empty QR frame.
|
||||||
|
qr.setVisibility(View.GONE);
|
||||||
|
code.setVisibility(View.GONE);
|
||||||
|
dialog.setOnDismissListener(d -> cancelled[0] = true);
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the request: QR code, the code in the sentence and below it. UI thread. */
|
||||||
|
void showRequest(PairingClient.Request request, android.graphics.Bitmap bitmap, int scanText, int waitingText) {
|
||||||
|
instructions.setText(getString(scanText, request.verificationUri, request.userCode));
|
||||||
|
if (bitmap != null) {
|
||||||
|
qr.setImageBitmap(bitmap);
|
||||||
|
qr.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
code.setText(request.userCode);
|
||||||
|
code.setVisibility(View.VISIBLE);
|
||||||
|
status.setText(waitingText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the dialog up with the outcome instead of closing it. UI thread. */
|
||||||
|
void finish(String message, int color) {
|
||||||
qr.setVisibility(View.GONE);
|
qr.setVisibility(View.GONE);
|
||||||
code.setVisibility(View.GONE);
|
code.setVisibility(View.GONE);
|
||||||
instructions.setVisibility(View.GONE);
|
instructions.setVisibility(View.GONE);
|
||||||
status.setText(message);
|
status.setText(message);
|
||||||
status.setTextColor(c);
|
status.setTextColor(color);
|
||||||
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
||||||
if (close != null) close.setText(R.string.pair_close);
|
if (close != null) close.setText(R.string.pair_close);
|
||||||
});
|
}
|
||||||
}, "server-pairing").start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -845,6 +949,15 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (config.chargingPrice != null || config.usableKwh != null) {
|
||||||
|
// Through the same Save as the fields (and the phone form), with the same limits.
|
||||||
|
String applied = applyPhoneSettings(new PhoneSettingsClient.Settings(
|
||||||
|
null, null, config.chargingPrice, config.usableKwh));
|
||||||
|
if (!applied.equals(getString(R.string.phone_settings_applied))) {
|
||||||
|
setConnectionStatus(COLOR_ERROR, getString(R.string.import_err_values));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
SharedPreferences.Editor edit = prefs.edit();
|
SharedPreferences.Editor edit = prefs.edit();
|
||||||
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
if (config.intervalSec != null) edit.putInt(UploadSettings.KEY_INTERVAL_SEC, config.intervalSec);
|
||||||
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
if (config.boostLowSoc != null) edit.putBoolean(UploadSettings.KEY_BOOST_LOW_SOC, config.boostLowSoc);
|
||||||
@@ -898,6 +1011,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
|
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
|
||||||
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
|
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
|
||||||
servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig());
|
servicePane.findViewById(R.id.abrp_test_button).setOnClickListener(v -> testAbrpConfig());
|
||||||
|
servicePane.findViewById(R.id.abrp_phone_button).setOnClickListener(v -> startPhoneSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String text(TextInputEditText input) {
|
private static String text(TextInputEditText input) {
|
||||||
@@ -1266,6 +1380,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
||||||
chargeSessionTracker.pricePerKwh()));
|
chargeSessionTracker.pricePerKwh()));
|
||||||
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
||||||
|
settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveChargePrice() {
|
private void saveChargePrice() {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's side of entering settings on a phone: open a request on the server (with this
|
||||||
|
* car's token), show it as a QR code, then poll until the phone has sent the values, which
|
||||||
|
* the server hands out once. Same codes and QR flow as pairing ({@link PairingClient}).
|
||||||
|
* Blocking calls — run them off the main thread.
|
||||||
|
*/
|
||||||
|
final class PhoneSettingsClient {
|
||||||
|
|
||||||
|
/** What the phone sent; null fields were left empty there. */
|
||||||
|
static final class Settings {
|
||||||
|
final String abrpApiKey;
|
||||||
|
final String abrpToken;
|
||||||
|
final Double pricePerKwh;
|
||||||
|
final Double usableKwh;
|
||||||
|
|
||||||
|
Settings(String abrpApiKey, String abrpToken, Double pricePerKwh, Double usableKwh) {
|
||||||
|
this.abrpApiKey = abrpApiKey;
|
||||||
|
this.abrpToken = abrpToken;
|
||||||
|
this.pricePerKwh = pricePerKwh;
|
||||||
|
this.usableKwh = usableKwh;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Settings parse(String body) throws Exception {
|
||||||
|
JSONObject json = new JSONObject(body);
|
||||||
|
return new Settings(text(json, "abrpApiKey"), text(json, "abrpToken"),
|
||||||
|
json.has("pricePerKwh") ? json.getDouble("pricePerKwh") : null,
|
||||||
|
json.has("usableKwh") ? json.getDouble("usableKwh") : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JSONObject json, String key) {
|
||||||
|
String value = json.optString(key, "").trim();
|
||||||
|
return value.isEmpty() ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PhoneSettingsClient() { }
|
||||||
|
|
||||||
|
static PairingClient.Request start(ServerConfig config) throws Exception {
|
||||||
|
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/start", config.token,
|
||||||
|
new JSONObject().put("installationId", config.installationId));
|
||||||
|
if (r.status == 404) throw new PairingClient.NotEnabledException();
|
||||||
|
if (r.status != 201) throw new PairingClient.HttpStatusException(r.status);
|
||||||
|
JSONObject json = new JSONObject(r.body);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
return new PairingClient.Request(
|
||||||
|
json.getString("deviceCode"),
|
||||||
|
json.getString("userCode"),
|
||||||
|
json.getString("verificationUri"),
|
||||||
|
json.getString("verificationUriComplete"),
|
||||||
|
now + json.optLong("expiresIn", 600) * 1000L,
|
||||||
|
Math.max(2, json.optLong("interval", 5)) * 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null while the phone has not sent anything; throws once the request has expired (410). */
|
||||||
|
static Settings poll(ServerConfig config, String deviceCode) throws Exception {
|
||||||
|
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/settings/poll", config.token,
|
||||||
|
new JSONObject().put("deviceCode", deviceCode));
|
||||||
|
if (r.status == 202) return null;
|
||||||
|
if (r.status == 200) return Settings.parse(r.body);
|
||||||
|
throw new PairingClient.HttpStatusException(r.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||||
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_save_button" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_save" android:layout_marginTop="20dp" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_test_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_test" android:layout_marginTop="8dp" />
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/abrp_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||||
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
<TextView android:id="@+id/abrp_status_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:textAppearance="@style/Text.EV.Body" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/settings_phone_button" style="@style/Widget.EV.Button.Outlined" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn_phone_settings" android:layout_marginTop="8dp" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/hint_phone_settings" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/wifi_card_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
<string name="import_ok_deleted">Config imported and saved; the file was deleted</string>
|
<string name="import_ok_deleted">Config imported and saved; the file was deleted</string>
|
||||||
<string name="import_ok_not_deleted">Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s</string>
|
<string name="import_ok_not_deleted">Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s</string>
|
||||||
<string name="import_err_read">Could not read that file</string>
|
<string name="import_err_read">Could not read that file</string>
|
||||||
|
<string name="import_err_values">The charging price or usable capacity in the file is not plausible</string>
|
||||||
<string name="import_err_empty">No settings found in that file</string>
|
<string name="import_err_empty">No settings found in that file</string>
|
||||||
<string name="import_err_not_found">No config file found. Copy it to %1$s</string>
|
<string name="import_err_not_found">No config file found. Copy it to %1$s</string>
|
||||||
|
|
||||||
@@ -134,6 +135,16 @@
|
|||||||
<string name="abrp_test_failed">ABRP refused: HTTP %1$d</string>
|
<string name="abrp_test_failed">ABRP refused: HTTP %1$d</string>
|
||||||
<string name="abrp_test_offline">ABRP not reachable: %1$s</string>
|
<string name="abrp_test_offline">ABRP not reachable: %1$s</string>
|
||||||
<string name="abrp_not_set">Set both first</string>
|
<string name="abrp_not_set">Set both first</string>
|
||||||
|
<string name="btn_phone_settings">Enter on phone</string>
|
||||||
|
<string name="hint_phone_settings">Type the ABRP credentials, charging price and usable capacity on your phone instead: a QR code opens a form on your server.</string>
|
||||||
|
<string name="phone_settings_title">Enter settings on your phone</string>
|
||||||
|
<string name="phone_settings_scan">Scan the QR code with your phone, or open %1$s and enter the code %2$s. Fill in what you want to change and send it with the approval password.</string>
|
||||||
|
<string name="phone_settings_waiting">Waiting for the phone…</string>
|
||||||
|
<string name="phone_settings_needs_server">Set up the server first (Server tab): the form for the phone runs on your server.</string>
|
||||||
|
<string name="phone_settings_not_enabled">Not available on the server: it needs version 2.5.0 or newer and an APPROVAL_PASSWORD</string>
|
||||||
|
<string name="pair_expired_settings">The code expired — press Enter on phone again</string>
|
||||||
|
<string name="phone_settings_applied">Settings from the phone saved</string>
|
||||||
|
<string name="phone_settings_partly">Some values from the phone were not plausible and were not saved</string>
|
||||||
|
|
||||||
|
|
||||||
<!-- Cadence configuration -->
|
<!-- Cadence configuration -->
|
||||||
|
|||||||
@@ -96,4 +96,14 @@ public class ConfigImportTest {
|
|||||||
assertFalse(c.isEmpty());
|
assertFalse(c.isEmpty());
|
||||||
assertEquals("token-2", ConfigImport.parse("abrp_user_token = token-2").abrpToken);
|
assertEquals("token-2", ConfigImport.parse("abrp_user_token = token-2").abrpToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void chargingPriceAndUsableCapacityAreRead() {
|
||||||
|
ConfigImport c = ConfigImport.parse("charging_price = 0,39\nusable_kwh = 50.8\n");
|
||||||
|
assertEquals(0.39, c.chargingPrice, 1e-9);
|
||||||
|
assertEquals(50.8, c.usableKwh, 1e-9);
|
||||||
|
assertFalse(c.isEmpty());
|
||||||
|
assertNull(ConfigImport.parse("charging_price = cheap").chargingPrice);
|
||||||
|
assertEquals(0.5, ConfigImport.parse("price_per_kwh = 0.5").chargingPrice, 1e-9);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/** Reading what the phone sent, as the server hands it out — pure JVM. */
|
||||||
|
public class PhoneSettingsClientTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void everythingTheServerSends() throws Exception {
|
||||||
|
PhoneSettingsClient.Settings s = PhoneSettingsClient.Settings.parse(
|
||||||
|
"{\"abrpApiKey\":\"api-key-1234\",\"abrpToken\":\" user-token-5678 \",\"pricePerKwh\":0.39,\"usableKwh\":50.8}");
|
||||||
|
assertEquals("api-key-1234", s.abrpApiKey);
|
||||||
|
assertEquals("user-token-5678", s.abrpToken);
|
||||||
|
assertEquals(0.39, s.pricePerKwh, 1e-9);
|
||||||
|
assertEquals(50.8, s.usableKwh, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whatThePhoneLeftEmptyStaysUnset() throws Exception {
|
||||||
|
PhoneSettingsClient.Settings s = PhoneSettingsClient.Settings.parse("{\"abrpToken\":\"user-token-5678\"}");
|
||||||
|
assertNull(s.abrpApiKey);
|
||||||
|
assertNull(s.pricePerKwh);
|
||||||
|
assertNull(s.usableKwh);
|
||||||
|
assertEquals("user-token-5678", s.abrpToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -29,8 +29,8 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.4.0, pinned by digest so a re-pushed tag cannot change what runs.
|
# 2.5.0, pinned by digest so a re-pushed tag cannot change what runs.
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:ab243f37812efa7294efc594f65ca65f9d551161a85e7cc641afb168e226a2fe
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:93b82b24a649c7b8b6fb7c0d4ef0bfda5694689808991a211e22ddeb257a39e2
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ with (403 otherwise); the shared token works for any.
|
|||||||
|
|
||||||
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
|
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
|
||||||
|
|
||||||
|
## Settings from a phone
|
||||||
|
|
||||||
|
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
||||||
|
opens `POST /v1/settings/start` `{installationId}` and shows `verificationUriComplete` as a QR
|
||||||
|
code. The phone fills in the form at `/settings?code=…` (ABRP API key and user token, charging
|
||||||
|
price, usable capacity; empty fields are left alone) and sends it with the approval password.
|
||||||
|
The car polls `POST /v1/settings/poll` `{deviceCode}`: 202 pending, 200 with the values (handed
|
||||||
|
out once, `Cache-Control: no-store`), 410 expired or unknown. A paired token only reaches its own
|
||||||
|
installation's request. The values live in memory only, at most ten minutes, and are never
|
||||||
|
stored, logged or put back into the page.
|
||||||
|
|
||||||
## Pairing a car by QR code
|
## Pairing a car by QR code
|
||||||
|
|
||||||
So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628);
|
So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
||||||
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
||||||
// GET /v1/health unauthenticated liveness probe
|
// GET /v1/health unauthenticated liveness probe
|
||||||
|
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
|
||||||
//
|
//
|
||||||
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
||||||
// (pairing_http.go).
|
// (pairing_http.go).
|
||||||
@@ -25,6 +26,8 @@ type API struct {
|
|||||||
Store Store
|
Store Store
|
||||||
IngestToken string
|
IngestToken string
|
||||||
Pairing *Pairing
|
Pairing *Pairing
|
||||||
|
// Handoff carries settings typed on a phone to the car (handoff.go).
|
||||||
|
Handoff *Handoff
|
||||||
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||||
// derived from the request when empty.
|
// derived from the request when empty.
|
||||||
PublicURL string
|
PublicURL string
|
||||||
@@ -42,6 +45,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
|
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
|
||||||
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
||||||
a.pairingRoutes(mux)
|
a.pairingRoutes(mux)
|
||||||
|
a.handoffRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ const (
|
|||||||
func newTestAPI() (*API, *fakeStore) {
|
func newTestAPI() (*API, *fakeStore) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
return &API{Store: store, IngestToken: testToken,
|
return &API{Store: store, IngestToken: testToken,
|
||||||
Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store
|
Pairing: NewPairing(testApprovalPassword, nil), Handoff: NewHandoff(), PublicURL: "https://car.example.com"}, store
|
||||||
}
|
}
|
||||||
|
|
||||||
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handoff lets a phone fill in settings the car would otherwise need its on-screen keyboard
|
||||||
|
// for: the ABRP credentials above all, which are long. The car opens a request and shows it as
|
||||||
|
// a QR code, the phone fills in a form (with the approval password), and the car, polling,
|
||||||
|
// collects the values once.
|
||||||
|
//
|
||||||
|
// The values only pass through: they are held in memory until the car collects them, or for
|
||||||
|
// the request's ten minutes, and are never stored, logged or echoed back to the page.
|
||||||
|
type Handoff struct {
|
||||||
|
Now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
byDevice map[string]*handoffRequest
|
||||||
|
byUser map[string]*handoffRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPendingHandoffs bounds memory; only an authenticated car can open one.
|
||||||
|
const maxPendingHandoffs = 200
|
||||||
|
|
||||||
|
// PhoneSettings is what the phone may set. A nil or empty field leaves the car's value alone.
|
||||||
|
type PhoneSettings struct {
|
||||||
|
AbrpAPIKey string `json:"abrpApiKey,omitempty"`
|
||||||
|
AbrpToken string `json:"abrpToken,omitempty"`
|
||||||
|
PricePerKwh *float64 `json:"pricePerKwh,omitempty"`
|
||||||
|
UsableKwh *float64 `json:"usableKwh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s PhoneSettings) empty() bool {
|
||||||
|
return s.AbrpAPIKey == "" && s.AbrpToken == "" && s.PricePerKwh == nil && s.UsableKwh == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handoffRequest struct {
|
||||||
|
deviceCode string
|
||||||
|
userCode string
|
||||||
|
installationID string
|
||||||
|
expires time.Time
|
||||||
|
settings *PhoneSettings // set by the phone, handed to the car once
|
||||||
|
}
|
||||||
|
|
||||||
|
var errAlreadyFilled = errors.New("these settings were already sent")
|
||||||
|
|
||||||
|
func NewHandoff() *Handoff {
|
||||||
|
return &Handoff{Now: time.Now, byDevice: map[string]*handoffRequest{}, byUser: map[string]*handoffRequest{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handoff) expireLocked(now time.Time) {
|
||||||
|
for code, r := range h.byDevice {
|
||||||
|
if now.After(r.expires) {
|
||||||
|
delete(h.byDevice, code)
|
||||||
|
delete(h.byUser, r.userCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start opens a request for a car; a new one replaces the same installation's open request.
|
||||||
|
func (h *Handoff) Start(installationID string) (*handoffRequest, error) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
now := h.Now()
|
||||||
|
h.expireLocked(now)
|
||||||
|
for _, r := range h.byDevice {
|
||||||
|
if strings.EqualFold(r.installationID, installationID) {
|
||||||
|
delete(h.byDevice, r.deviceCode)
|
||||||
|
delete(h.byUser, r.userCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(h.byDevice) >= maxPendingHandoffs {
|
||||||
|
return nil, errTooMany
|
||||||
|
}
|
||||||
|
r := &handoffRequest{deviceCode: randomString(32), installationID: installationID, expires: now.Add(pairingTTL)}
|
||||||
|
for {
|
||||||
|
r.userCode = newUserCode()
|
||||||
|
if _, taken := h.byUser[r.userCode]; !taken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.byDevice[r.deviceCode] = r
|
||||||
|
h.byUser[r.userCode] = r
|
||||||
|
out := *r
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open reports whether a user code belongs to a request still waiting for the phone.
|
||||||
|
func (h *Handoff) Open(userCode string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.expireLocked(h.Now())
|
||||||
|
r, ok := h.byUser[normalizeUserCode(userCode)]
|
||||||
|
return ok && r.settings == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill stores what the phone entered, once.
|
||||||
|
func (h *Handoff) Fill(userCode string, s PhoneSettings) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.expireLocked(h.Now())
|
||||||
|
r, ok := h.byUser[normalizeUserCode(userCode)]
|
||||||
|
if !ok {
|
||||||
|
return errUnknownCode
|
||||||
|
}
|
||||||
|
if r.settings != nil {
|
||||||
|
return errAlreadyFilled
|
||||||
|
}
|
||||||
|
r.settings = &s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll is the car's side: nil while the phone has not sent anything, the settings exactly
|
||||||
|
// once, then errUnknownCode. installationID must be the one the request was opened for.
|
||||||
|
func (h *Handoff) Poll(deviceCode string, mayUse func(installationID string) bool) (*PhoneSettings, error) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.expireLocked(h.Now())
|
||||||
|
r, ok := h.byDevice[deviceCode]
|
||||||
|
if !ok || !mayUse(r.installationID) {
|
||||||
|
return nil, errUnknownCode
|
||||||
|
}
|
||||||
|
if r.settings == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
delete(h.byDevice, r.deviceCode)
|
||||||
|
delete(h.byUser, r.userCode)
|
||||||
|
return r.settings, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP side of entering settings on the phone (handoff.go).
|
||||||
|
//
|
||||||
|
// POST /v1/settings/start car, with its token: open a request {installationId} → codes and URL
|
||||||
|
// POST /v1/settings/poll car, with its token: collect {deviceCode} → 202 / 200 settings / 410
|
||||||
|
// GET /settings?code=… phone: the form
|
||||||
|
// POST /settings phone: send it (needs the approval password)
|
||||||
|
//
|
||||||
|
// Switched on together with pairing, by APPROVAL_PASSWORD: the phone page needs it.
|
||||||
|
|
||||||
|
func (a *API) handoffRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /v1/settings/start", a.ingest(a.settingsStart))
|
||||||
|
mux.HandleFunc("POST /v1/settings/poll", a.ingest(a.settingsPoll))
|
||||||
|
mux.HandleFunc("GET /settings", a.settingsPage)
|
||||||
|
mux.HandleFunc("POST /settings", a.settingsSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handoffEnabled() bool { return a.Handoff != nil && a.Pairing.Enabled() }
|
||||||
|
|
||||||
|
var errHandoffOff = errors.New("entering settings on the phone is not enabled")
|
||||||
|
|
||||||
|
func (a *API) settingsStart(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||||
|
if !a.handoffEnabled() {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
o, err := decodeObject(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := validUUID(o["installationId"], "installationId")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !c.mayUse(id) {
|
||||||
|
return errOtherInstallation
|
||||||
|
}
|
||||||
|
req, err := a.Handoff.Start(id)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := a.publicURL(r) + "/settings"
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"deviceCode": req.deviceCode,
|
||||||
|
"userCode": req.userCode,
|
||||||
|
"verificationUri": base,
|
||||||
|
"verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode),
|
||||||
|
"expiresIn": int(pairingTTL / time.Second),
|
||||||
|
"interval": int(pairingInterval / time.Second),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) settingsPoll(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||||
|
if !a.handoffEnabled() {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": errHandoffOff.Error()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
o, err := decodeObject(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
code, _ := o["deviceCode"].(string)
|
||||||
|
settings, err := a.Handoff.Poll(code, c.mayUse)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"})
|
||||||
|
case settings == nil:
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"})
|
||||||
|
default:
|
||||||
|
// The answer carries credentials: never cached anywhere on the way.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Phone page ----------
|
||||||
|
|
||||||
|
type settingsPageData struct {
|
||||||
|
Code string
|
||||||
|
Price string
|
||||||
|
Usable string
|
||||||
|
Error string
|
||||||
|
Done bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Car settings — Nils' MG4</title>
|
||||||
|
<style>` + pageStyle + `.hint{font-size:13px;color:#8E9597;margin:4px 0 0}</style></head><body><main>
|
||||||
|
<h1>Car settings</h1>
|
||||||
|
{{if .Done}}
|
||||||
|
<p class="ok">{{.Message}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p>Fill in what you want to change; empty fields leave the car's value as it is. The car picks the values up within a few seconds.</p>
|
||||||
|
<form method="post" action="/settings" autocomplete="off">
|
||||||
|
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
||||||
|
{{else}}<label for="code">Code shown on the car</label>
|
||||||
|
<input id="code" name="code" autocapitalize="characters" placeholder="XXX-XXX" required>{{end}}
|
||||||
|
<label for="abrpApiKey">ABRP API key</label>
|
||||||
|
<input id="abrpApiKey" name="abrpApiKey" type="password" autocomplete="off" spellcheck="false">
|
||||||
|
<label for="abrpToken">ABRP user token</label>
|
||||||
|
<input id="abrpToken" name="abrpToken" type="password" autocomplete="off" spellcheck="false">
|
||||||
|
<p class="hint">The car needs both to send live data to ABRP.</p>
|
||||||
|
<label for="price">Charging price (€/kWh)</label>
|
||||||
|
<input id="price" name="price" inputmode="decimal" value="{{.Price}}">
|
||||||
|
<label for="usable">Usable battery capacity (kWh)</label>
|
||||||
|
<input id="usable" name="usable" inputmode="decimal" value="{{.Usable}}">
|
||||||
|
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
|
||||||
|
<label for="password">Approval password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<div class="row"><button class="approve">Send to the car</button></div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</main></body></html>`))
|
||||||
|
|
||||||
|
func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := settingsTemplate.Execute(w, data); err != nil {
|
||||||
|
slog.Warn("settings page not rendered", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) settingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.handoffEnabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := settingsPageData{}
|
||||||
|
if raw := r.URL.Query().Get("code"); raw != "" {
|
||||||
|
if a.Handoff.Open(raw) {
|
||||||
|
data.Code = normalizeUserCode(raw)
|
||||||
|
} else {
|
||||||
|
data.Error = "This code is unknown or has expired. Start again on the car."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderSettingsPage(w, http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// phoneSettingsFromForm checks what the phone sent. Credentials are never put back into the
|
||||||
|
// page; the numbers are, so a typo is fixed without typing them again.
|
||||||
|
func phoneSettingsFromForm(form url.Values) (PhoneSettings, error) {
|
||||||
|
var s PhoneSettings
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
dst *string
|
||||||
|
}{{"abrpApiKey", &s.AbrpAPIKey}, {"abrpToken", &s.AbrpToken}} {
|
||||||
|
v := strings.TrimSpace(form.Get(f.name))
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(v) < 8 || len(v) > 200 || strings.ContainsAny(v, " \t\r\n") {
|
||||||
|
return PhoneSettings{}, errors.New("the ABRP API key or user token does not look right")
|
||||||
|
}
|
||||||
|
*f.dst = v
|
||||||
|
}
|
||||||
|
for _, f := range []struct {
|
||||||
|
name string
|
||||||
|
min, max float64
|
||||||
|
dst **float64
|
||||||
|
label string
|
||||||
|
}{{"price", 0, 100, &s.PricePerKwh, "charging price"}, {"usable", 10, 150, &s.UsableKwh, "usable capacity"}} {
|
||||||
|
v := strings.ReplaceAll(strings.TrimSpace(form.Get(f.name)), ",", ".")
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil || n < f.min || n > f.max {
|
||||||
|
return PhoneSettings{}, errors.New("the " + f.label + " is not a plausible number")
|
||||||
|
}
|
||||||
|
*f.dst = &n
|
||||||
|
}
|
||||||
|
if s.empty() {
|
||||||
|
return PhoneSettings{}, errors.New("nothing filled in")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) settingsSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.handoffEnabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 8192)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
renderSettingsPage(w, http.StatusBadRequest, settingsPageData{Error: "The form could not be read."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := normalizeUserCode(r.PostForm.Get("code"))
|
||||||
|
back := settingsPageData{Code: code, Price: r.PostForm.Get("price"), Usable: r.PostForm.Get("usable")}
|
||||||
|
switch err := a.Pairing.CheckAdmin(r.PostForm.Get("password"), clientAddress(r)); {
|
||||||
|
case errors.Is(err, errTooManyFailed):
|
||||||
|
back.Error = err.Error() + "."
|
||||||
|
renderSettingsPage(w, http.StatusTooManyRequests, back)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
back.Error = "Wrong password."
|
||||||
|
renderSettingsPage(w, http.StatusUnauthorized, back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := phoneSettingsFromForm(r.PostForm)
|
||||||
|
if err != nil {
|
||||||
|
back.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
|
||||||
|
renderSettingsPage(w, http.StatusBadRequest, back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch err := a.Handoff.Fill(code, settings); {
|
||||||
|
case errors.Is(err, errAlreadyFilled):
|
||||||
|
renderSettingsPage(w, http.StatusConflict, settingsPageData{Error: "These settings were already sent. Start again on the car to send more."})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
renderSettingsPage(w, http.StatusNotFound, settingsPageData{Error: "This code is unknown or has expired. Start again on the car."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("settings sent from a phone")
|
||||||
|
renderSettingsPage(w, http.StatusOK, settingsPageData{Done: true,
|
||||||
|
Message: "Sent. The car picks the settings up within a few seconds — you can close this page."})
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startHandoff(t *testing.T, h http.Handler, token string) (deviceCode, userCode string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, h, "POST", "/v1/settings/start", token, `{"installationId":"`+testInstallation+`"}`)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("start: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["verificationUriComplete"] != "https://car.example.com/settings?code="+got["userCode"].(string) {
|
||||||
|
t.Fatalf("uri %v", got["verificationUriComplete"])
|
||||||
|
}
|
||||||
|
return got["deviceCode"].(string), got["userCode"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSettings(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsTravelFromThePhoneToTheCarOnce(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
if rec := do(t, h, "POST", "/v1/settings/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 401 {
|
||||||
|
t.Fatalf("start without a token: %d", rec.Code)
|
||||||
|
}
|
||||||
|
deviceCode, userCode := startHandoff(t, h, testToken)
|
||||||
|
poll := func() *httptest.ResponseRecorder {
|
||||||
|
return do(t, h, "POST", "/v1/settings/poll", testToken, `{"deviceCode":"`+deviceCode+`"}`)
|
||||||
|
}
|
||||||
|
if rec := poll(); rec.Code != 202 {
|
||||||
|
t.Fatalf("before the phone: %d", rec.Code)
|
||||||
|
}
|
||||||
|
page := do(t, h, "GET", "/settings?code="+userCode, "", "")
|
||||||
|
if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) {
|
||||||
|
t.Fatalf("page %d", page.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{"code": {userCode}, "abrpApiKey": {"api-key-1234"}, "abrpToken": {"user-token-5678"},
|
||||||
|
"price": {"0,39"}, "password": {"wrong"}}
|
||||||
|
if rec := sendSettings(t, h, form); rec.Code != 401 || strings.Contains(rec.Body.String(), "user-token-5678") {
|
||||||
|
t.Fatalf("wrong password: %d (credentials must not be echoed)", rec.Code)
|
||||||
|
}
|
||||||
|
form.Set("password", testApprovalPassword)
|
||||||
|
if rec := sendSettings(t, h, form); rec.Code != 200 {
|
||||||
|
t.Fatalf("send: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if rec := sendSettings(t, h, form); rec.Code != 409 {
|
||||||
|
t.Fatalf("sent twice: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := poll()
|
||||||
|
var got PhoneSettings
|
||||||
|
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &got) != nil {
|
||||||
|
t.Fatalf("collect: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if got.AbrpAPIKey != "api-key-1234" || got.AbrpToken != "user-token-5678" || got.PricePerKwh == nil ||
|
||||||
|
*got.PricePerKwh != 0.39 || got.UsableKwh != nil {
|
||||||
|
t.Fatalf("settings %+v", got)
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||||
|
t.Fatal("credentials cacheable")
|
||||||
|
}
|
||||||
|
if rec := poll(); rec.Code != 410 {
|
||||||
|
t.Fatalf("collected twice: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsAreOnlyForTheCarThatAsked(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode := startHandoff(t, h, testToken)
|
||||||
|
_ = api.Handoff.Fill(userCode, PhoneSettings{AbrpToken: "user-token-5678"})
|
||||||
|
// A car paired as another installation cannot open a request for this one, nor collect it.
|
||||||
|
other := caller{installationID: "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c"}
|
||||||
|
if s, err := api.Handoff.Poll(deviceCode, other.mayUse); s != nil || err == nil {
|
||||||
|
t.Fatal("another car collected the settings")
|
||||||
|
}
|
||||||
|
if s, err := api.Handoff.Poll(deviceCode, caller{}.mayUse); s == nil || err != nil {
|
||||||
|
t.Fatalf("the car itself could not collect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhoneSettingsAreChecked(t *testing.T) {
|
||||||
|
for name, form := range map[string]url.Values{
|
||||||
|
"nothing": {},
|
||||||
|
"short token": {"abrpToken": {"abc"}},
|
||||||
|
"spaced key": {"abrpApiKey": {"has a space here"}},
|
||||||
|
"price absurd": {"price": {"250"}},
|
||||||
|
"usable too low": {"usable": {"3"}},
|
||||||
|
"not a number": {"price": {"cheap"}},
|
||||||
|
} {
|
||||||
|
if _, err := phoneSettingsFromForm(form); err == nil {
|
||||||
|
t.Errorf("%s accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, err := phoneSettingsFromForm(url.Values{"usable": {" 50,8 "}})
|
||||||
|
if err != nil || s.UsableKwh == nil || *s.UsableKwh != 50.8 || s.AbrpToken != "" {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoffRequestsExpireAndAreReplaced(t *testing.T) {
|
||||||
|
h := NewHandoff()
|
||||||
|
first, _ := h.Start(testInstallation)
|
||||||
|
second, _ := h.Start(testInstallation)
|
||||||
|
if _, err := h.Poll(first.deviceCode, caller{}.mayUse); err == nil {
|
||||||
|
t.Fatal("the replaced request is still open")
|
||||||
|
}
|
||||||
|
h.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) }
|
||||||
|
if err := h.Fill(second.userCode, PhoneSettings{AbrpToken: "user-token-5678"}); err == nil {
|
||||||
|
t.Fatal("an expired request took settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhoneSettingsNeedPairingSwitchedOn(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
api.Pairing = NewPairing("", nil)
|
||||||
|
h := api.Routes()
|
||||||
|
if rec := do(t, h, "POST", "/v1/settings/start", testToken, `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 {
|
||||||
|
t.Fatalf("start: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/settings", "", ""); rec.Code != 404 {
|
||||||
|
t.Fatalf("page: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -87,7 +87,8 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
pairing := NewPairing(cfg.approvalPass, devices)
|
pairing := NewPairing(cfg.approvalPass, devices)
|
||||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, PublicURL: cfg.publicURL}
|
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
||||||
|
PublicURL: cfg.publicURL}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.listenAddr,
|
Addr: cfg.listenAddr,
|
||||||
|
|||||||
@@ -147,10 +147,8 @@ type pageData struct {
|
|||||||
Done bool
|
Done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
|
// pageStyle is shared by the phone pages (pairing, settings): dark, MG red, no scripts.
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
const pageStyle = `
|
||||||
<title>Pair a car — Nils' MG4</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:dark}
|
:root{color-scheme:dark}
|
||||||
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
|
||||||
main{width:min(92vw,420px);background:#121212;border:1px solid #363B3D;border-radius:16px;padding:28px}
|
main{width:min(92vw,420px);background:#121212;border:1px solid #363B3D;border-radius:16px;padding:28px}
|
||||||
@@ -162,7 +160,12 @@ input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1p
|
|||||||
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
|
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
|
||||||
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
|
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
|
||||||
.error{color:#FFB4AB}.ok{color:#A4DCC1}
|
.error{color:#FFB4AB}.ok{color:#A4DCC1}
|
||||||
</style></head><body><main>
|
`
|
||||||
|
|
||||||
|
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Pair a car — Nils' MG4</title>
|
||||||
|
<style>` + pageStyle + `</style></head><body><main>
|
||||||
<h1>Pair a car</h1>
|
<h1>Pair a car</h1>
|
||||||
{{if .Done}}
|
{{if .Done}}
|
||||||
<p class="ok">{{.Message}}</p>
|
<p class="ok">{{.Message}}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user