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:
@@ -15,6 +15,8 @@ package com.evsuite.abrp;
|
||||
* low_soc_percent = 20
|
||||
* abrp_api_key = your-abrp-api-key (optional: send live data to ABRP directly)
|
||||
* abrp_token = your-abrp-user-token
|
||||
* charging_price = 0.39 (€/kWh)
|
||||
* usable_kwh = 50.8 (usable battery capacity, for the range estimates)
|
||||
* </pre>
|
||||
*
|
||||
* 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 String abrpApiKey;
|
||||
final String abrpToken;
|
||||
final Double chargingPrice;
|
||||
final Double usableKwh;
|
||||
|
||||
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.token = token;
|
||||
this.installationId = installationId;
|
||||
@@ -44,18 +49,22 @@ final class ConfigImport {
|
||||
this.lowSocPercent = lowSocPercent;
|
||||
this.abrpApiKey = abrpApiKey;
|
||||
this.abrpToken = abrpToken;
|
||||
this.chargingPrice = chargingPrice;
|
||||
this.usableKwh = usableKwh;
|
||||
}
|
||||
|
||||
/** True when the file yielded nothing usable — an empty or malformed file, not a config. */
|
||||
boolean isEmpty() {
|
||||
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) {
|
||||
String serverUrl = null, token = null, installationId = null, abrpApiKey = null, abrpToken = null;
|
||||
Integer intervalSec = null, lowSocPercent = null;
|
||||
Boolean boostLowSoc = null;
|
||||
Double chargingPrice = null, usableKwh = null;
|
||||
|
||||
for (String line : text.split("\n", -1)) {
|
||||
line = line.trim();
|
||||
@@ -98,6 +107,14 @@ final class ConfigImport {
|
||||
case "abrp_user_token":
|
||||
abrpToken = value;
|
||||
break;
|
||||
case "charging_price":
|
||||
case "price_per_kwh":
|
||||
chargingPrice = parseDecimal(value);
|
||||
break;
|
||||
case "usable_kwh":
|
||||
case "usable_capacity_kwh":
|
||||
usableKwh = parseDecimal(value);
|
||||
break;
|
||||
default:
|
||||
// Unknown keys are ignored rather than rejected, so a file can carry
|
||||
// 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,
|
||||
abrpApiKey, abrpToken);
|
||||
abrpApiKey, abrpToken, chargingPrice, usableKwh);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
value = value.toLowerCase(java.util.Locale.US);
|
||||
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)
|
||||
? typedId : ServerConfig.get(this).installationId;
|
||||
|
||||
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(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();
|
||||
PhoneQrDialog ui = new PhoneQrDialog(R.string.pair_title, R.string.pair_starting);
|
||||
final boolean[] cancelled = ui.cancelled;
|
||||
|
||||
new Thread(() -> {
|
||||
String outcome;
|
||||
@@ -603,18 +540,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
try {
|
||||
PairingClient.Request request = PairingClient.start(url, installationId);
|
||||
android.graphics.Bitmap bitmap = QrCode.generate(request.verificationUriComplete, 416);
|
||||
runOnUiThread(() -> {
|
||||
// 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);
|
||||
});
|
||||
runOnUiThread(() -> ui.showRequest(request, bitmap, R.string.pair_scan, R.string.pair_waiting));
|
||||
PairingClient.PollResult result = null;
|
||||
while (!cancelled[0] && System.currentTimeMillis() < request.expiresAtMs) {
|
||||
Thread.sleep(request.intervalMs);
|
||||
@@ -655,23 +581,201 @@ public class MainActivity extends AppCompatActivity {
|
||||
showServerConfig();
|
||||
serverTokenLayout.setError(null);
|
||||
refreshStatus();
|
||||
if (!cancelled[0]) dialog.dismiss();
|
||||
if (!cancelled[0]) ui.dialog.dismiss();
|
||||
return;
|
||||
}
|
||||
if (cancelled[0]) return;
|
||||
// Keep the dialog up with the reason, instead of closing it on a failure the
|
||||
// driver would otherwise only see as a flicker.
|
||||
qr.setVisibility(View.GONE);
|
||||
code.setVisibility(View.GONE);
|
||||
instructions.setVisibility(View.GONE);
|
||||
status.setText(message);
|
||||
status.setTextColor(c);
|
||||
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
||||
if (close != null) close.setText(R.string.pair_close);
|
||||
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);
|
||||
code.setVisibility(View.GONE);
|
||||
instructions.setVisibility(View.GONE);
|
||||
status.setText(message);
|
||||
status.setTextColor(color);
|
||||
android.widget.Button close = dialog.getButton(android.content.DialogInterface.BUTTON_NEGATIVE);
|
||||
if (close != null) close.setText(R.string.pair_close);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A network failure in words the driver can act on: a name that does not resolve, a
|
||||
* server that does not answer, or a status. Shared by Test connection and pairing.
|
||||
@@ -845,6 +949,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
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();
|
||||
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);
|
||||
@@ -898,6 +1011,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
servicePane.findViewById(R.id.abrp_save_button).setOnClickListener(v ->
|
||||
saveAbrpConfig(text(abrpApiKeyInput), text(abrpTokenInput)));
|
||||
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) {
|
||||
@@ -1266,6 +1380,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f",
|
||||
chargeSessionTracker.pricePerKwh()));
|
||||
settingsPane.findViewById(R.id.charge_price_save).setOnClickListener(v -> saveChargePrice());
|
||||
settingsPane.findViewById(R.id.settings_phone_button).setOnClickListener(v -> startPhoneSettings());
|
||||
}
|
||||
|
||||
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.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_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" />
|
||||
</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.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/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="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.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_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" />
|
||||
</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">
|
||||
|
||||
@@ -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.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/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 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" />
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
<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_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_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_offline">ABRP not reachable: %1$s</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 -->
|
||||
|
||||
Reference in New Issue
Block a user