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
+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
}