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:
2026-10-18 12:59:03 +02:00
co-authored by Claude Opus 5.5
parent 9f184362f0
commit d711e0cfda
22 changed files with 918 additions and 100 deletions
+11
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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,23 +581,201 @@ 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.
qr.setVisibility(View.GONE); if (!cancelled[0]) ui.finish(message, c);
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);
}); });
}, "server-pairing").start(); }, "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 * 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. * 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; 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">
+1
View File
@@ -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" />
+11
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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 {
+132
View File
@@ -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
}
+240
View File
@@ -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."})
}
+143
View File
@@ -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
View File
@@ -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,
+8 -5
View File
@@ -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>