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
@@ -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.
## 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
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
// GET /v1/consumption/history/{installationId} the car reads its own totals back
// 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
// (pairing_http.go).
@@ -25,6 +26,8 @@ type API struct {
Store Store
IngestToken string
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;
// derived from the request when empty.
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("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
a.pairingRoutes(mux)
a.handoffRoutes(mux)
return mux
}
+1 -1
View File
@@ -77,7 +77,7 @@ const (
func newTestAPI() (*API, *fakeStore) {
store := &fakeStore{}
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 {
+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)
}
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{
Addr: cfg.listenAddr,
+8 -5
View File
@@ -147,10 +147,8 @@ type pageData struct {
Done bool
}
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 is shared by the phone pages (pairing, settings): dark, MG red, no scripts.
const pageStyle = `
: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}
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}
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
.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>
{{if .Done}}
<p class="ok">{{.Message}}</p>