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>
1038 lines
35 KiB
Go
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)
|
|
}
|