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