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, 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 // // 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 Forwarder *Forwarder 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"}) } } } // notYetAtAbrp leaves out the samples the car already sent to ABRP itself. func notYetAtAbrp(samples []TelemetrySample, sentUTC int64) []TelemetrySample { if sentUTC == 0 { return samples } out := make([]TelemetrySample, 0, len(samples)) for _, s := range samples { if s.UTC > sentUTC { out = append(out, s) } } return out } // maxDroppedListed caps the dropped values named in a telemetry response and log line. const maxDroppedListed = 20 // 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. 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 } a.Forwarder.Forward(notYetAtAbrp(batch.Samples, batch.AbrpSentUTC)) } 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) } }