The car sends live data to ABRP itself, and the car and the server go over the same mobile connection: when the car cannot reach ABRP, it cannot reach the server either, so the server's fallback bought nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone; the server only stores. The app stops sending abrpSentUtc (the server ignores unknown fields, so 2.11.0 keeps working) and its texts no longer promise a server fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
221 lines
7.4 KiB
Go
221 lines
7.4 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
|
|
//
|
|
// 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
|
|
// 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)
|
|
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)
|
|
}
|
|
}
|