Files
DriverVault/API Server/internal/api/vehicleproviders.go
T
tajniak81andClaude Opus 5 2b6da642ad Provider: show the electric range with the climate control on
MyToyota reports two range figures for an EV, and only one of them was
reaching the readings: evRangeWithAc was listed as a fallback alias for
evRange, so on a car that reports both — every bZ4X — the first key won
and the second was never shown. It is its own reading now. The gap
between the two is the useful part: it is what running the A/C costs you.

Both are labelled for the pair, "Electric range (A/C off)" beside
"Electric range (A/C on)", so neither figure is left ambiguous now that
they sit next to each other.

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

1038 lines
35 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
}
// 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 {
specs := []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},
}
out := []providerMetric{}
for _, spec := range specs {
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)
}