package main import ( "crypto/subtle" "encoding/json" "errors" "io" "log/slog" "net/http" "strings" ) // API serves the car. Every endpoint but /v1/health needs the ingest token. // // POST /v1/telemetry live samples; stored, newest forwarded to ABRP // 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 type API struct { Store Store IngestToken string Forwarder *Forwarder } 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)) return mux } // hasIngestToken compares in constant time. main refuses to start with a token under 24 // characters, so an empty or short configured token can never match here. func (a *API) hasIngestToken(r *http.Request) bool { header := r.Header.Get("Authorization") supplied, ok := strings.CutPrefix(header, "Bearer ") if !ok || len(a.IngestToken) < minIngestTokenLength { return false } return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 } type handler func(w http.ResponseWriter, r *http.Request, 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; 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) { if !a.hasIngestToken(r) { 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, body); err != nil { 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"}) } } } // telemetry stores the batch first; only once the write succeeded is the newest sample // handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the // database never got. func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error { batch, err := validateTelemetryBatch(body) if err != nil { return err } if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil { return err } a.Forwarder.Forward(batch.Samples) w.WriteHeader(http.StatusNoContent) 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, body []byte) error { chunk, err := validateConsumption(body) if err != nil { return err } 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, body []byte) error { session, err := validateChargingSession(body, r.PathValue("sessionId")) if err != nil { return err } 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, _ []byte) error { id, err := validUUID(r.PathValue("installationId"), "installationId") if err != nil { return err } 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) } }