Files
mg4/server/api.go
T
nilsandClaude Opus 5.5 d711e0cfda 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
2026-10-18 12:59:03 +02:00

225 lines
7.6 KiB
Go

package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// API serves the car. Every endpoint but /v1/health needs the ingest token.
//
// POST /v1/telemetry live samples, stored
// PUT /v1/consumption/daily one delta chunk of a day's consumption
// 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).
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
}
const maxBodyBytes = 512 * 1024
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry))
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
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
}
// caller is who sent an ingest request. A paired car's token is bound to the installation
// id it was paired with; the shared INGEST_TOKEN may write and read for any.
type caller struct {
installationID string // empty for the shared token
}
// mayUse reports whether the caller may write or read data stored under id.
func (c caller) mayUse(id string) bool {
return c.installationID == "" || strings.EqualFold(c.installationID, id)
}
// errOtherInstallation is a 403: a paired car's token used for another car's data.
var errOtherInstallation = errors.New("this token belongs to another installation; pair again after changing the installation id")
// authenticate accepts the shared token (compared in constant time; main refuses to start
// with one under 24 characters, so an empty or short one never matches) or a paired car's
// own, unrevoked token.
func (a *API) authenticate(r *http.Request) (caller, bool) {
header := r.Header.Get("Authorization")
supplied, ok := strings.CutPrefix(header, "Bearer ")
if !ok || supplied == "" {
return caller{}, false
}
if len(a.IngestToken) >= minIngestTokenLength &&
subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 {
return caller{}, true
}
d, ok := a.Pairing.Authorized(supplied)
if !ok {
return caller{}, false
}
return caller{installationID: d.InstallationID}, true
}
type handler func(w http.ResponseWriter, r *http.Request, c caller, body []byte) error
// ingest wraps a handler with the token check, the body limit and error mapping: a
// validation failure is a 400 naming the field, another car's data a 403; anything else
// is a 500 whose detail stays in the server log.
func (a *API) ingest(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, ok := a.authenticate(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"})
return
}
if len(body) > maxBodyBytes {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
return
}
if err := h(w, r, c, body); err != nil {
if errors.Is(err, errOtherInstallation) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
var verr *ValidationError
if errors.As(err, &verr) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
return
}
slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
}
// maxDroppedListed caps the dropped values named in a telemetry response and log line.
const maxDroppedListed = 20
// telemetry stores the batch. The answer says how many samples were stored and what was
// dropped; a batch with nothing usable is still a 200, since sending it again would not help.
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
batch, err := validateTelemetryBatch(body, time.Now())
if err != nil {
return err
}
if !c.mayUse(batch.InstallationID) {
return errOtherInstallation
}
if len(batch.Samples) > 0 {
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
return err
}
}
dropped := batch.Dropped
if len(dropped) > maxDroppedListed {
dropped = dropped[:maxDroppedListed]
}
if len(batch.Dropped) > 0 {
slog.Warn("telemetry values dropped", "installation", batch.InstallationID,
"count", len(batch.Dropped), "first", dropped)
}
writeJSON(w, http.StatusOK, map[string]any{
"stored": len(batch.Samples),
"droppedCount": len(batch.Dropped),
"dropped": append([]string{}, dropped...),
})
return nil
}
// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a
// chunk the car re-sends from its offline cache replaces itself instead of counting twice.
func (a *API) consumption(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
chunk, err := validateConsumption(body)
if err != nil {
return err
}
if !c.mayUse(chunk.InstallationID) {
return errOtherInstallation
}
if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
session, err := validateChargingSession(body, r.PathValue("sessionId"))
if err != nil {
return err
}
if !c.mayUse(session.InstallationID) {
return errOtherInstallation
}
if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// history is the car's read-back of its own uploaded totals, so an app reinstall does not
// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all.
func (a *API) history(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
id, err := validUUID(r.PathValue("installationId"), "installationId")
if err != nil {
return err
}
if !c.mayUse(id) {
return errOtherInstallation
}
chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
if err != nil {
return err
}
days := aggregateDays(chunks)
var lifetime Totals
if len(days) > 0 {
lifetime = days[0].Lifetime
}
if len(days) > 400 {
days = days[:400]
}
writeJSON(w, http.StatusOK, struct {
Records []DailyConsumption `json:"records"`
Lifetime Totals `json:"lifetime"`
}{Records: days, Lifetime: lifetime})
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Warn("response not written", "error", err)
}
}