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
133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
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
|
|
}
|