A car can now be imported straight from the account its owner already has
with the manufacturer, and every reading that service exposes shows up on
the car's own tab. MyToyota is the first provider.
API Server — internal/api/vehicleproviders.go adds a generic layer over a
plugin that can enumerate vehicles and read data about them. Adding the
next manufacturer is one vehicleSource adapter plus a line in
vehicleSources(): no new endpoints, no Web App changes.
GET /api/vehicle-providers providers + connect state
GET /api/vehicle-providers/{p}/vehicles the caller's vehicles
POST /api/vehicle-providers/{p}/import create a car from one
GET /api/cars/{id}/provider live snapshot for the tab
POST /api/cars/{id}/provider link / unlink a car
POST /api/cars/{id}/provider/sync re-apply provider data
Two properties shape it. Credentials are always the caller's own, resolved
through the same global -> org -> user cascade as the integration settings,
so a shared car shows provider data only when that vehicle is on the
viewer's account — the owner's credentials are never borrowed. And upstream
shapes are not modelled: these are unofficial APIs, so the layer searches
payloads by key name for the readings worth promoting (odometer, fuel,
battery, range) and flattens the rest to dotted key/value pairs alongside
the raw JSON. A renamed field costs one blank value, not a broken page.
The Toyota gate and its wording now live in toyotaSource, so the older
/api/integrations/toyota/vehicles endpoint and the new ones cannot drift.
Manager.InvokeBatchWith shares one transient plugin instance across a batch
of actions. The tab pulls seven capabilities, and InvokeWith builds a fresh
instance per call — which for a connector that authenticates lazily means a
fresh OAuth login per call. Batching logs in once.
cars gains provider + provider_vehicle_id (schema.go and
setup-pocketbase.mjs both). carPayload deliberately omits them, so an
ordinary car edit can neither reassign the car nor break its link;
carProviderPayload writes the link on its own.
Web App — Dashboard grows an "import from service" button beside "add car",
shown only once an account is connected, opening CarImportModal: pick the
vehicle, choose what to pull (identity / fuel type / dates / odometer, all
on by default), import. ProviderPanel becomes the car's first tab, ahead of
Information, labelled with the service: headline readings, the vehicle
record, one card per capability with its raw response, and an offer to take
the provider's odometer when it is ahead of the stored one. On an unlinked
car the tab instead offers to link it, VIN-matched. Info stays the default
selection — landing on the provider tab would fire a login on every car
page view. Full en/pl/da translations.
Tests cover the payload walking, Toyota normalization, import-selection
defaults, and — through the real handler chain against a stand-in
PocketBase — that every route is registered and that a closed gate is soft
on a listing (200 + a reason the UI can show) but hard on a write (4xx, so
a caller cannot read the reply as a created car).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1033 lines
35 KiB
Go
1033 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", "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)
|
|
}
|