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:
@@ -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."})
|
||||
}
|
||||
Reference in New Issue
Block a user