Files
DriverVault/API Server/internal/api/vehicleproviders.go
T
tajniak81andClaude Opus 5 6191160f14 Cars: log what charging costs, and drag the provider readings
Three things, all on a car's page.

A Charging cost tab, which is the Fuel cost tab written for an electric
car: charges in kWh, consumption in kWh/100km beside km per kWh, cost per
km and price per kWh, and the same summary panel over the whole history.
It keeps the reference-point method too, and has to — a session records
the energy that went in, not what was left in the battery, so a given
number of kWh only maps to a distance between two charges that ended at
the same state. A charge to the car's usual full point plays the part of
the full tank; partial charges still count towards the cost and roll into
the next full one; and a charge taken without logging it leaves its
window uncomputed rather than reporting an implausibly good figure.
Sessions live in their own collection, the figures are derived on read
like the fuel ones, and logging a charge advances the odometer exactly as
a refill does. The tab switches off from the gear like every other, so a
petrol car need never see it.

The headline readings on the connected-service tab now drag into any
order, saved on drop. Stored on the car as metricOrder, like the
Information rows, rather than per device the way the collapsed cards are:
an arrangement is something everyone the car is shared with should see,
where a folded card is one browser's reading habit. Only what the
provider reported can be arranged, so a reading that turns up later joins
the end rather than displacing the arrangement.

Fuel is now Fuel cost, tab and heading, which is what the tab has always
been about and what pairs it with Charging cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:17:52 +02:00

1046 lines
36 KiB
Go

package api
// Vehicle providers turn a manufacturer-service plugin into two user-facing
// things: a car you can create straight from your account with the service, and
// a per-car tab showing everything that service currently knows about it.
//
// The layer is deliberately generic. A provider is a small adapter (vehicleSource)
// over an existing plugin plus its per-user credential cascade, so adding the
// next manufacturer means writing one adapter and appending it to
// vehicleSources() — no new endpoints, no new UI plumbing. Toyota (MyToyota) is
// the first one; see vehicleproviders_toyota.go.
//
// GET /api/vehicle-providers — providers, with connect state
// GET /api/vehicle-providers/{provider}/vehicles — the caller's vehicles
// POST /api/vehicle-providers/{provider}/import — create a car from one
// GET /api/cars/{id}/provider — live snapshot for the tab
// POST /api/cars/{id}/provider — link / unlink an existing car
// POST /api/cars/{id}/provider/sync — re-apply provider data to the car
//
// Two properties are worth stating outright, because they shape the whole design:
//
// Credentials are always the *caller's*. Every call runs under the account the
// requesting user connected in Settings (resolved through the global → org → user
// cascade in integrations.go). A car shared with someone else therefore shows
// them provider data only if that vehicle is on their own manufacturer account —
// the owner's credentials are never borrowed, and never leave the server.
//
// Upstream shapes are not modelled. These are unofficial APIs whose payloads
// change without notice, so rather than hard-coding field paths this file walks
// whatever JSON comes back: findMeasure/findString locate the handful of readings
// worth promoting (odometer, fuel, battery, range), and flattenJSON turns the
// rest into dotted key/value pairs so the tab can show everything the plugin
// returned. A shape change degrades a field to "not shown" instead of breaking
// the page.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"drivervault/apiserver/internal/models"
"drivervault/apiserver/internal/plugins"
)
// vehicleSource adapts one plugin that can enumerate the caller's vehicles and
// read data about them.
type vehicleSource interface {
// id is the URL segment and the value persisted on car.provider.
id() string
// label names the provider to the user — the car tab's title ("MyToyota").
label() string
// service is the upstream service behind it ("Toyota Connected Europe").
service() string
// gate resolves the caller's effective credentials for this provider from the
// integration cascade. When ok is false nothing is called and detail says, in
// one sentence, what the user has to do about it. userRaw is the caller's
// pluginSettings blob, passed in so one request reads it once.
gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (cfg map[string]string, ok bool, detail string)
// listAction is the plugin capability that enumerates vehicles.
listAction() string
// vehicles maps that capability's payload onto normalized entries.
vehicles(raw json.RawMessage) []providerVehicle
// sections are the per-vehicle capabilities the tab fetches, in display order.
sections() []providerSection
}
// vehicleSources are the registered providers, in menu order.
func vehicleSources() []vehicleSource {
return []vehicleSource{toyotaSource{}}
}
func vehicleSourceByID(id string) (vehicleSource, bool) {
for _, src := range vehicleSources() {
if src.id() == id {
return src, true
}
}
return nil, false
}
// providerSection is one per-vehicle capability, rendered as a card in the tab.
// The Web App localizes the heading from ID, so nothing here is English.
type providerSection struct {
ID string // stable id, e.g. "telemetry"
Action string // the plugin capability to invoke
}
// providerField is one leaf of a provider payload, flattened to a dotted path.
// Everything a plugin returns becomes one of these, which is what lets the tab
// show a whole payload without this server modelling each upstream schema.
type providerField struct {
Key string `json:"key"`
Value string `json:"value"`
}
// providerVehicle is one vehicle on the caller's provider account, normalized
// into the fields a Car is built from. Raw and Fields carry the upstream object
// verbatim and flattened, so the UI can show more than we map.
type providerVehicle struct {
ID string `json:"id"` // the provider's own id (VIN, for Toyota)
VIN string `json:"vin,omitempty"`
Name string `json:"name"`
Make string `json:"make,omitempty"`
Model string `json:"model,omitempty"`
Year int `json:"year,omitempty"`
Registration string `json:"registration,omitempty"`
FuelType string `json:"fuelType,omitempty"`
BuildDate string `json:"buildDate,omitempty"`
FirstRegistrationDate string `json:"firstRegistrationDate,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Fields []providerField `json:"fields,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
// LinkedCarID is set when the caller already has a car linked to this
// vehicle, so the UI can offer to open it instead of importing it twice.
LinkedCarID string `json:"linkedCarId,omitempty"`
}
// providerMetric is a headline reading lifted out of the sections — the few
// values worth showing large. Key is a stable id the Web App localizes.
type providerMetric struct {
Key string `json:"key"`
Value string `json:"value"`
Unit string `json:"unit,omitempty"`
}
// sectionResult is one capability's outcome. A section that fails carries its
// error and the rest still render: half a snapshot beats an error page.
type sectionResult struct {
ID string `json:"id"`
Status string `json:"status"` // ok | error | empty
Error string `json:"error,omitempty"`
Fields []providerField `json:"fields,omitempty"`
// Truncated reports that Fields was capped (see maxSectionFields).
Truncated bool `json:"truncated,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
// providerSnapshot is everything a provider can currently tell us about one car.
type providerSnapshot struct {
Provider string `json:"provider"`
Label string `json:"label"`
Service string `json:"service"`
VehicleID string `json:"vehicleId,omitempty"`
// Unavailable + Detail replace the payload when the caller cannot reach the
// provider at all (not connected, or this vehicle is not on their account).
Unavailable bool `json:"unavailable,omitempty"`
Detail string `json:"detail,omitempty"`
FetchedAt string `json:"fetchedAt,omitempty"`
Vehicle *providerVehicle `json:"vehicle,omitempty"`
Metrics []providerMetric `json:"metrics,omitempty"`
Sections []sectionResult `json:"sections,omitempty"`
// SuggestedCurrentKm is the odometer the provider reports, when it differs
// from the car's stored reading — what the tab's "update odometer" offers.
SuggestedCurrentKm int `json:"suggestedCurrentKm,omitempty"`
}
// maxSectionFields caps how many flattened leaves one section returns. A
// notification history can run to hundreds of entries; the raw payload is still
// attached, so nothing is lost — only the pre-flattened list is bounded.
const maxSectionFields = 300
// ---- provider listing --------------------------------------------------------
// GET /api/vehicle-providers — the registered providers and whether the caller
// can currently use each one. Never 4xx for a provider that is merely not
// connected: the Web App shows those as "connect in Settings".
func (s *Server) handleListVehicleProviders(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
out := make([]map[string]any, 0, len(vehicleSources()))
for _, src := range vehicleSources() {
_, ok, detail := src.gate(r.Context(), s, who, userRaw)
out = append(out, map[string]any{
"id": src.id(),
"label": src.label(),
"service": src.service(),
"connected": ok,
"detail": detail,
})
}
writeJSON(w, http.StatusOK, map[string]any{"providers": out})
}
// resolveSource looks up the provider named in the path and gates it for the
// caller. On any failure it writes the response and returns ok=false.
//
// softGate picks how a closed gate is reported. A listing answers 200 with an
// empty list plus a reason, so the UI can say "connect this in Settings" instead
// of showing a failure; a write (import, link) answers 400, because there the
// caller asked for something that did not happen and must not read the reply as
// success.
func (s *Server) resolveSource(w http.ResponseWriter, r *http.Request, softGate bool) (vehicleSource, map[string]string, bool) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, nil, false
}
src, found := vehicleSourceByID(r.PathValue("provider"))
if !found {
writeError(w, http.StatusNotFound, "unknown vehicle provider")
return nil, nil, false
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
if !ok {
if softGate {
writeJSON(w, http.StatusOK, map[string]any{
"provider": src.id(), "label": src.label(),
"vehicles": []any{}, "unavailable": true, "detail": detail,
})
} else {
writeError(w, http.StatusBadRequest, detail)
}
return nil, nil, false
}
return src, cfg, true
}
// ---- vehicle listing ---------------------------------------------------------
// GET /api/vehicle-providers/{provider}/vehicles — the vehicles on the caller's
// account with that provider, normalized into importable car fields, each
// annotated with the car it is already linked to (if any).
func (s *Server) handleProviderVehicles(w http.ResponseWriter, r *http.Request) {
src, cfg, ok := s.resolveSource(w, r, true)
if !ok {
return
}
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Annotate with the caller's existing links so the UI never offers to import
// the same vehicle twice.
linked := s.linkedVehicles(r.Context(), s.currentUserID(r), src.id())
for i := range vehicles {
vehicles[i].LinkedCarID = linked[vehicles[i].ID]
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": src.id(),
"label": src.label(),
"service": src.service(),
"vehicles": vehicles,
})
}
// fetchProviderVehicles invokes the provider's list capability and normalizes it.
func (s *Server) fetchProviderVehicles(ctx context.Context, src vehicleSource, cfg map[string]string) ([]providerVehicle, error) {
raw, err := s.plugins.InvokeWith(ctx, src.id(), cfg, src.listAction(), nil)
if err != nil {
return nil, err
}
return src.vehicles(raw), nil
}
// linkedVehicles maps provider vehicle id -> car id for the cars a user owns
// under one provider. Best effort: an unreachable PocketBase yields an empty map
// and the UI simply shows nothing as linked.
func (s *Server) linkedVehicles(ctx context.Context, userID, provider string) map[string]string {
out := map[string]string{}
if userID == "" || provider == "" {
return out
}
res, err := s.pb.List(ctx, colCars, url.Values{
"filter": {fmt.Sprintf("owner='%s' && provider='%s'", userID, provider)},
"perPage": {"200"},
})
if err != nil {
return out
}
var recs []carRecord
if json.Unmarshal(res.Items, &recs) != nil {
return out
}
for _, rec := range recs {
if rec.ProviderVehicleID != "" {
out[rec.ProviderVehicleID] = rec.ID
}
}
return out
}
// findVehicle picks one vehicle out of the caller's provider account by id,
// falling back to a VIN match so a client may address it either way.
func findVehicle(vehicles []providerVehicle, id string) (providerVehicle, bool) {
id = strings.TrimSpace(id)
if id == "" {
return providerVehicle{}, false
}
for _, v := range vehicles {
if strings.EqualFold(v.ID, id) || (v.VIN != "" && strings.EqualFold(v.VIN, id)) {
return v, true
}
}
return providerVehicle{}, false
}
// ---- import ------------------------------------------------------------------
// importSelection is the caller's choice of what to pull from the provider. Each
// field is a tri-state: absent means "yes", which makes the plain request
// {"vehicleId": …} mean "fetch everything you can".
type importSelection struct {
Identity *bool `json:"identity"` // make, model, year, registration, VIN
FuelType *bool `json:"fuelType"`
Dates *bool `json:"dates"` // build / first-registration dates
Odometer *bool `json:"odometer"` // current km, from the telemetry capability
}
// resolvedSelection is an importSelection with the defaults applied.
type resolvedSelection struct{ identity, fuelType, dates, odometer bool }
func (in *importSelection) resolve() resolvedSelection {
pick := func(v *bool) bool { return v == nil || *v }
if in == nil {
return resolvedSelection{true, true, true, true}
}
return resolvedSelection{pick(in.Identity), pick(in.FuelType), pick(in.Dates), pick(in.Odometer)}
}
// POST /api/vehicle-providers/{provider}/import — create a car from one vehicle
// on the caller's provider account. Body:
//
// {vehicleId, name?, include?: {identity, fuelType, dates, odometer}}
//
// The new car is owned by the caller and linked to the provider, so its tab works
// immediately. Importing a vehicle the caller already has is refused with 409 and
// the existing car's id, so a double submit cannot duplicate a car.
func (s *Server) handleProviderImport(w http.ResponseWriter, r *http.Request) {
var body struct {
VehicleID string `json:"vehicleId"`
Name string `json:"name"`
Include *importSelection `json:"include"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
src, cfg, ok := s.resolveSource(w, r, false)
if !ok {
return
}
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
vehicle, found := findVehicle(vehicles, body.VehicleID)
if !found {
writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
return
}
me := s.currentUserID(r)
if existing := s.linkedVehicles(r.Context(), me, src.id())[vehicle.ID]; existing != "" {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "this vehicle is already in your garage",
"carId": existing,
})
return
}
sel := body.Include.resolve()
car := models.Car{Name: strings.TrimSpace(body.Name)}
applyVehicleToCar(&car, vehicle, sel)
if car.Name == "" {
car.Name = vehicle.Name
}
if car.Name == "" {
car.Name = src.label() + " vehicle"
}
// The odometer is not part of the vehicle list — it comes from a per-vehicle
// capability, so it costs an extra call and is only made when asked for.
var warnings []string
if sel.odometer {
if km, ok := s.providerOdometer(r.Context(), src, cfg, vehicle.ID); ok {
car.CurrentKm = km
} else {
warnings = append(warnings, "odometer")
}
}
applyCarDefaults(&car)
payload := carPayload(car)
payload["owner"] = me
for k, v := range carProviderPayload(src.id(), vehicle.ID) {
payload[k] = v
}
var rec carRecord
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
m.Access = accessOwner
writeJSON(w, http.StatusCreated, map[string]any{"car": m, "warnings": warnings})
}
// applyVehicleToCar copies the selected groups of a provider vehicle onto a car.
// Only non-empty provider values are written, so a field the provider does not
// report is left as it was rather than blanked.
func applyVehicleToCar(car *models.Car, v providerVehicle, sel resolvedSelection) {
set := func(dst *string, val string) {
if strings.TrimSpace(val) != "" {
*dst = val
}
}
if sel.identity {
set(&car.Make, v.Make)
set(&car.Model, v.Model)
set(&car.Registration, v.Registration)
set(&car.VIN, v.VIN)
if v.Year > 0 {
car.Year = v.Year
}
}
if sel.fuelType {
set(&car.FuelType, v.FuelType)
}
if sel.dates {
set(&car.BuildDate, v.BuildDate)
set(&car.FirstRegistrationDate, v.FirstRegistrationDate)
}
}
// providerOdometer reads the odometer for one vehicle out of whichever section
// reports it, walking the payload rather than assuming a path. Kilometres are the
// stored unit, so a reading in miles is converted.
//
// The sections go out as one batch rather than a call each: InvokeWith builds a
// fresh instance per call, which for a connector that authenticates lazily means
// a fresh login per call too. Sections are scanned in the provider's declared
// order, so the endpoint meant to carry the odometer wins over one that happens
// to mention a distance.
func (s *Server) providerOdometer(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) (int, bool) {
defs := src.sections()
calls := make([]plugins.BatchCall, 0, len(defs))
for _, sec := range defs {
calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
}
results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
if err != nil {
return 0, false
}
for _, res := range results {
if res.Err != nil || len(res.Result) == 0 {
continue
}
var tree any
if json.Unmarshal(res.Result, &tree) != nil {
continue
}
if km, ok := odometerKm(tree); ok {
return km, true
}
}
return 0, false
}
// odometerKm extracts an odometer reading in kilometres from a decoded payload.
func odometerKm(tree any) (int, bool) {
n, unit, ok := findMeasure(tree, "odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal")
if !ok || n <= 0 {
return 0, false
}
if isMiles(unit) {
n *= 1.609344
}
return int(math.Round(n)), true
}
func isMiles(unit string) bool {
u := strings.ToLower(strings.TrimSpace(unit))
return u == "mi" || u == "mile" || u == "miles" || u == "imperial"
}
// vehicleParams is the {"vin": …} params object every per-vehicle capability
// takes. The key is "vin" because that is what the plugin contract uses; the
// value is the provider's vehicle id, which for Toyota is the VIN.
func vehicleParams(vehicleID string) json.RawMessage {
b, _ := json.Marshal(map[string]string{"vin": vehicleID})
return b
}
// ---- per-car snapshot --------------------------------------------------------
// GET /api/cars/{id}/provider — everything the car's provider can currently tell
// us about it. Requires read access to the car; the provider call runs under the
// *caller's* own account, so a sharee sees data only for a vehicle that is also
// on their account (and a clear reason when it is not).
func (s *Server) handleCarProvider(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
level, rec, err := s.carAccessLevel(r.Context(), who.ID, r.PathValue("id"))
if err != nil {
writePBError(w, err)
return
}
if level == accessNone {
writeError(w, http.StatusForbidden, "you do not have access to this car")
return
}
if rec.Provider == "" {
writeError(w, http.StatusNotFound, "this car is not linked to a connected service")
return
}
src, found := vehicleSourceByID(rec.Provider)
if !found {
writeError(w, http.StatusNotFound, "unknown vehicle provider")
return
}
snap := providerSnapshot{
Provider: src.id(),
Label: src.label(),
Service: src.service(),
VehicleID: rec.ProviderVehicleID,
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
if !ok {
snap.Unavailable, snap.Detail = true, detail
writeJSON(w, http.StatusOK, snap)
return
}
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
if err != nil {
snap.Unavailable, snap.Detail = true, err.Error()
writeJSON(w, http.StatusOK, snap)
return
}
vehicle, found := findVehicle(vehicles, rec.ProviderVehicleID)
if !found {
snap.Unavailable = true
snap.Detail = "this vehicle is not on your " + src.label() + " account"
writeJSON(w, http.StatusOK, snap)
return
}
snap.Vehicle = &vehicle
snap.Sections, snap.Metrics = s.fetchSections(r.Context(), src, cfg, vehicle.ID)
snap.FetchedAt = time.Now().UTC().Format(time.RFC3339)
// Offer the provider's odometer when it is ahead of what the car has stored.
for _, m := range snap.Metrics {
if m.Key != "odometer" {
continue
}
if km, err := strconv.Atoi(m.Value); err == nil && km > rec.CurrentKm {
snap.SuggestedCurrentKm = km
}
}
writeJSON(w, http.StatusOK, snap)
}
// fetchSections invokes every per-vehicle capability in one batch (so a connector
// that authenticates lazily logs in once) and turns each payload into a flattened
// section plus, across all of them, the headline metrics.
func (s *Server) fetchSections(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) ([]sectionResult, []providerMetric) {
defs := src.sections()
calls := make([]plugins.BatchCall, 0, len(defs))
for _, sec := range defs {
calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
}
results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
if err != nil {
return nil, nil
}
out := make([]sectionResult, 0, len(results))
trees := make([]any, 0, len(results))
for _, res := range results {
sec := sectionResult{ID: res.ID, Status: "ok"}
switch {
case res.Err != nil:
sec.Status, sec.Error = "error", res.Err.Error()
case len(res.Result) == 0:
sec.Status = "empty"
default:
var tree any
if err := json.Unmarshal(res.Result, &tree); err != nil {
sec.Status, sec.Error = "error", "the service returned data this app could not read"
} else {
sec.Raw = res.Result
sec.Fields, sec.Truncated = flattenJSON(tree, maxSectionFields)
if len(sec.Fields) == 0 {
sec.Status = "empty"
}
trees = append(trees, tree)
}
}
out = append(out, sec)
}
return out, headlineMetrics(trees)
}
// metricSpec declares one headline reading: where to look for it and how to
// present it. The first section that reports a value wins.
type metricSpec struct {
key string
keys []string
unit string // fixed unit when the payload does not carry one
distance bool // convert an imperial reading to kilometres
}
// headlineMetricSpecs are the measured readings, in the order they are reported
// when a car has no arrangement of its own. Package-level so the set of keys a
// car's arrangement may name (arrangeableCarMetrics, in cars.go) is derived from
// it and the two cannot drift apart.
var headlineMetricSpecs = []metricSpec{
{key: "odometer", keys: []string{"odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal"}, unit: "km", distance: true},
{key: "fuelLevel", keys: []string{"fuelLevel", "fuelPercentage", "fuelRemainingPercent"}, unit: "%"},
{key: "fuelRange", keys: []string{"fuelRange", "rangeRemaining", "drivingRange", "fuelRangeTotal"}, unit: "km", distance: true},
{key: "batteryLevel", keys: []string{"batteryLevel", "chargeRemainingAmount", "stateOfCharge", "socLevel"}, unit: "%"},
{key: "evRange", keys: []string{"evRange", "electricRange", "batteryRange"}, unit: "km", distance: true},
// Range with the climate control running, which Toyota reports beside the
// plain one. Its own reading rather than a fallback for evRange: the two
// are different numbers and the gap between them is the point — a driver
// deciding whether to run the A/C wants to see both.
{key: "evRangeWithAc", keys: []string{"evRangeWithAc"}, unit: "km", distance: true},
}
// unmeasuredMetricKeys are the headline readings that are not measures — a state
// and a pair of coordinates — picked out separately below.
var unmeasuredMetricKeys = []string{"chargingStatus", "location"}
// headlineMetrics lifts the readings worth showing large out of the section
// payloads. Everything not listed here still reaches the UI as a flattened
// field — this is about prominence, not about filtering.
func headlineMetrics(trees []any) []providerMetric {
out := []providerMetric{}
for _, spec := range headlineMetricSpecs {
for _, tree := range trees {
n, unit, ok := findMeasure(tree, spec.keys...)
if !ok {
continue
}
display := spec.unit
if spec.distance && isMiles(unit) {
n *= 1.609344
} else if unit != "" && spec.unit != "%" {
display = unit
}
out = append(out, providerMetric{Key: spec.key, Value: formatNumber(n), Unit: display})
break
}
}
// Charging state and the parked position are strings/pairs rather than
// measures, so they are picked out separately.
for _, tree := range trees {
if v := findString(tree, "chargingStatus", "chargeStatus", "chargeType"); v != "" {
out = append(out, providerMetric{Key: "chargingStatus", Value: v})
break
}
}
for _, tree := range trees {
lat, _, latOK := findMeasure(tree, "latitude", "lat")
lon, _, lonOK := findMeasure(tree, "longitude", "lon", "lng")
if latOK && lonOK {
out = append(out, providerMetric{
Key: "location",
Value: strconv.FormatFloat(lat, 'f', 5, 64) + ", " + strconv.FormatFloat(lon, 'f', 5, 64),
})
break
}
}
return out
}
// ---- link / unlink -----------------------------------------------------------
// POST /api/cars/{id}/provider — link this car to a vehicle on the caller's
// provider account, or unlink it. Body: {provider, vehicleId} — an empty
// provider unlinks. The vehicle must actually be on the caller's account, so a
// link can never point at a vehicle its owner cannot read.
//
// Write access is required, and the link is stored on its own (see
// carProviderPayload) so nothing else about the car changes.
func (s *Server) handleLinkCarProvider(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body struct {
Provider string `json:"provider"`
VehicleID string `json:"vehicleId"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessWrite) {
return
}
provider := strings.TrimSpace(body.Provider)
vehicleID := strings.TrimSpace(body.VehicleID)
if provider != "" {
src, found := vehicleSourceByID(provider)
if !found {
writeError(w, http.StatusNotFound, "unknown vehicle provider")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
if !ok {
writeError(w, http.StatusBadRequest, detail)
return
}
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
vehicle, found := findVehicle(vehicles, vehicleID)
if !found {
writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
return
}
vehicleID = vehicle.ID
}
var rec carRecord
if err := s.pb.Update(r.Context(), colCars, carID, carProviderPayload(provider, vehicleID), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
m.Access, _, _ = s.carAccessLevel(r.Context(), who.ID, carID)
writeJSON(w, http.StatusOK, m)
}
// POST /api/cars/{id}/provider/sync — re-apply the provider's data to the car.
// Body: {include?: {identity, fuelType, dates, odometer}}; the same selection the
// import takes, so "refresh the odometer" and "refresh everything" are one
// endpoint. Returns the updated car.
func (s *Server) handleSyncCarProvider(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body struct {
Include *importSelection `json:"include"`
}
// An empty body is allowed and means "everything": "sync this car" needs no
// arguments.
if err := decodeJSON(r, &body); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessWrite) {
return
}
var rec carRecord
if err := s.pb.GetOne(r.Context(), colCars, carID, &rec); err != nil {
writePBError(w, err)
return
}
if rec.Provider == "" {
writeError(w, http.StatusBadRequest, "this car is not linked to a connected service")
return
}
src, found := vehicleSourceByID(rec.Provider)
if !found {
writeError(w, http.StatusNotFound, "unknown vehicle provider")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
if !ok {
writeError(w, http.StatusBadRequest, detail)
return
}
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
vehicle, found := findVehicle(vehicles, rec.ProviderVehicleID)
if !found {
writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
return
}
sel := body.Include.resolve()
car := rec.toModel()
applyVehicleToCar(&car, vehicle, sel)
var warnings []string
if sel.odometer {
if km, ok := s.providerOdometer(r.Context(), src, cfg, vehicle.ID); ok && km > car.CurrentKm {
// Only ever forward: an odometer that appears to go backwards is the
// provider being stale, not the car having been driven in reverse.
car.CurrentKm = km
} else if !ok {
warnings = append(warnings, "odometer")
}
}
var updated carRecord
if err := s.pb.Update(r.Context(), colCars, carID, carPayload(car), &updated); err != nil {
writePBError(w, err)
return
}
m := updated.toModel()
m.Access, _, _ = s.carAccessLevel(r.Context(), who.ID, carID)
writeJSON(w, http.StatusOK, map[string]any{"car": m, "warnings": warnings})
}
// ---- JSON walking ------------------------------------------------------------
//
// The helpers below are why this file survives an upstream schema change. None of
// them know a single Toyota field path: they search a decoded payload by key name
// and flatten what is left.
// maxWalkNodes bounds every walk, so a pathological payload cannot spin the
// server. It is far above any real vehicle response.
const maxWalkNodes = 20000
// normalizeKey folds a JSON key to its comparable form: lowercase, letters and
// digits only. That makes "fuelLevel", "fuel_level" and "FUEL-LEVEL" one key.
func normalizeKey(k string) string {
var b strings.Builder
for _, r := range strings.ToLower(k) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
// sortedKeys returns a map's keys in order, so a walk is deterministic and two
// requests against the same payload never disagree about which field won.
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// findNode walks a decoded payload breadth-first and returns the first value
// stored under any of `keys`. Breadth-first is the point: a top-level "model"
// beats a "model" buried inside a dealer record, which is what makes searching by
// name tolerant of shapes this server has not modelled without picking the wrong
// field.
func findNode(root any, keys ...string) (any, bool) {
want := make(map[string]bool, len(keys))
for _, k := range keys {
want[normalizeKey(k)] = true
}
queue, visited := []any{root}, 0
for len(queue) > 0 && visited < maxWalkNodes {
node := queue[0]
queue = queue[1:]
visited++
switch v := node.(type) {
case map[string]any:
ks := sortedKeys(v)
for _, k := range ks {
if want[normalizeKey(k)] && v[k] != nil {
return v[k], true
}
}
for _, k := range ks {
queue = append(queue, v[k])
}
case []any:
queue = append(queue, v...)
}
}
return nil, false
}
// findString returns the first value under `keys` that reads as a non-empty
// string.
func findString(root any, keys ...string) string {
node, ok := findNode(root, keys...)
if !ok {
return ""
}
switch v := node.(type) {
case string:
return strings.TrimSpace(v)
case float64:
return formatNumber(v)
case bool:
return strconv.FormatBool(v)
case map[string]any:
// A wrapped value, e.g. {"value": "…"}.
if inner, ok := findNode(v, "value", "name", "description", "label"); ok {
if s, ok := inner.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
// findInt returns the first value under `keys` that reads as a whole number.
func findInt(root any, keys ...string) (int, bool) {
n, _, ok := findMeasure(root, keys...)
if !ok {
return 0, false
}
return int(math.Round(n)), true
}
// findMeasure returns the first numeric value under `keys`, plus the unit that
// travelled with it. It accepts the three shapes these APIs mix freely: a bare
// number, a numeric string, and a {value, unit} object.
func findMeasure(root any, keys ...string) (value float64, unit string, ok bool) {
node, found := findNode(root, keys...)
if !found {
return 0, "", false
}
switch v := node.(type) {
case float64:
return v, "", true
case string:
if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return n, "", true
}
case map[string]any:
inner, hasInner := findNode(v, "value", "amount", "quantity", "level")
if !hasInner {
return 0, "", false
}
u := findString(v, "unit", "uom", "units")
switch n := inner.(type) {
case float64:
return n, u, true
case string:
if parsed, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil {
return parsed, u, true
}
}
}
return 0, "", false
}
// flattenJSON turns a decoded payload into dotted key/value leaves, in a stable
// order, dropping nulls and empty strings. This is what lets the provider tab
// show a whole payload — including fields nobody has mapped yet — rather than
// only the handful this server understands.
//
// It returns truncated=true when it stopped at `limit`; the caller still ships
// the raw payload, so nothing is actually lost.
func flattenJSON(root any, limit int) (fields []providerField, truncated bool) {
var walk func(prefix string, node any)
walk = func(prefix string, node any) {
if len(fields) >= limit {
truncated = true
return
}
switch v := node.(type) {
case map[string]any:
for _, k := range sortedKeys(v) {
walk(joinPath(prefix, k), v[k])
}
case []any:
for i, e := range v {
walk(prefix+"["+strconv.Itoa(i)+"]", e)
}
case nil:
// A field the provider has no value for; omit rather than show "null".
case string:
if s := strings.TrimSpace(v); s != "" {
fields = append(fields, providerField{Key: prefix, Value: truncateValue(s)})
}
case float64:
fields = append(fields, providerField{Key: prefix, Value: formatNumber(v)})
case bool:
fields = append(fields, providerField{Key: prefix, Value: strconv.FormatBool(v)})
}
}
walk("", root)
return fields, truncated
}
func joinPath(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
// maxValueLength bounds one flattened value, so a base64 image blob in a payload
// cannot bloat the response.
const maxValueLength = 300
func truncateValue(s string) string {
if len(s) <= maxValueLength {
return s
}
return s[:maxValueLength] + "…"
}
// formatNumber renders a JSON number the way a person would write it: whole
// numbers without a decimal point, fractions without trailing zeros.
func formatNumber(n float64) string {
if n == math.Trunc(n) && math.Abs(n) < 1e15 {
return strconv.FormatInt(int64(n), 10)
}
return strconv.FormatFloat(n, 'f', -1, 64)
}