ABRP keeps getting live data while the car server is down: with an ABRP API key and user token set in the app (Live data tab, or abrp_api_key / abrp_token in the config file), the car sends the newest sample to ABRP itself at every upload. It tells the server the newest sample ABRP took (abrpSentUtc), and the server forwards only newer ones, so ABRP gets each sample once. Without credentials in the app the server forwards everything, as before. The credentials sit in encrypted storage, go in the request body, and neither they nor ABRP's answer are logged. Settings are grouped by what they are for: - Live data (was Upload): on/off, ABRP credentials, upload frequency. - Settings (replaces the Wi-Fi tab): full screen and voice key, usable capacity and manual SoH (were on Consumption), charging price (was on Charging), Wi-Fi. Charging and Live data get icons of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
239 lines
8.0 KiB
Go
239 lines
8.0 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, 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)
|
|
}
|
|
}
|