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(` Car settings — Nils' MG4

Car settings

{{if .Done}}

{{.Message}}

{{else}}

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.

{{if .Code}}
{{.Code}}
{{else}} ` + codeInput + `{{end}}

The car needs both to send live data to ABRP.

{{if .Error}}

{{.Error}}

{{end}}
{{end}}
`)) 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", pageCSP) 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."}) }