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}, // Cabin temperature and the preset it is climbing towards, out of the climate // section. Two readings for the same reason the two ranges are two: the gap // between them is the answer to the question the driver actually has, which is // whether the car is warm yet. Unit is fixed rather than converted — Toyota // Connected is a European service reporting Celsius — but a payload that names // its own unit still wins, as it does for every other reading here. {key: "cabinTemperature", keys: []string{"currentTemperature", "cabinTemperature", "insideTemperature"}, unit: "°C"}, {key: "targetTemperature", keys: []string{"targetTemperature"}, unit: "°C"}, } // roundForDisplay trims a reading to the precision it actually has. // // A range converted from miles arrives as 99.744 km, which claims to know the // remaining range to the metre and reads as false precision beside the 103.9 its // untouched twin reports. Distances keep one decimal and percentages none; // anything else is left alone, because without knowing what it measures there is // no safe place to cut. func roundForDisplay(n float64, spec metricSpec) float64 { switch { case spec.distance: return math.Round(n*10) / 10 case spec.unit == "%": return math.Round(n) default: return n } } // 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(roundForDisplay(n, spec)), 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) }