Both found by driving the installed app on a phone rather than by reading the code, which is worth noting: the second one is invisible in a simulator with gesture navigation turned off. The bZ4X's tab showed "Electric range (A/C on) 99.744 km" beside "Electric range (A/C off) 103.9 km". The long number is a reading converted out of miles: headlineMetrics multiplied by 1.609344 and printed whatever came out, so a range estimate claimed to know the distance to the metre, and the two readings disagreed about their own precision on the same card. Distances now keep one decimal and percentages none, applied by the reading's kind rather than by whether it was converted - a provider reporting 99.744 km natively gets the same treatment. Anything else is left alone, because without knowing what it measures there is no safe place to cut. The odometer already rounded to a whole number on its own path; this only changes the headline readings. The Add-user sheet's "Create user" button sat underneath the system navigation bar. Every one of these sheets padded its bottom with viewInsets.bottom, which is the keyboard - correct while typing and wrong the rest of the time, because with the keyboard down that inset is zero and the navigation bar is still there. They take the larger of the keyboard and the navigation bar now, since a raised keyboard covers the bar and the two must not be added. One helper on DriverVault rather than the same expression in six files, which is how the six drifted into being identical and identically wrong. Verified: go build, go vet and go test ./... pass, with a new test covering the conversion (62 mi reads 99.8 km), a native over-precise reading, a percentage, and the odometer's whole number surviving. flutter analyze clean, 21 tests pass, and the rebuilt release APK was installed on the phone - the Create user button now sits clear of the navigation bar, where the screenshot that prompted this showed it clipped. Not verified: the rounding is not visible on the phone yet. It talks to a deployed API Server that has not been rebuilt from this commit, so that tab will keep reading 99.744 until the server is redeployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1064 lines
36 KiB
Go
1064 lines
36 KiB
Go
package api
|
|
|
|
// Vehicle providers turn a manufacturer-service plugin into two user-facing
|
|
// things: a car you can create straight from your account with the service, and
|
|
// a per-car tab showing everything that service currently knows about it.
|
|
//
|
|
// The layer is deliberately generic. A provider is a small adapter (vehicleSource)
|
|
// over an existing plugin plus its per-user credential cascade, so adding the
|
|
// next manufacturer means writing one adapter and appending it to
|
|
// vehicleSources() — no new endpoints, no new UI plumbing. Toyota (MyToyota) is
|
|
// the first one; see vehicleproviders_toyota.go.
|
|
//
|
|
// GET /api/vehicle-providers — providers, with connect state
|
|
// GET /api/vehicle-providers/{provider}/vehicles — the caller's vehicles
|
|
// POST /api/vehicle-providers/{provider}/import — create a car from one
|
|
// GET /api/cars/{id}/provider — live snapshot for the tab
|
|
// POST /api/cars/{id}/provider — link / unlink an existing car
|
|
// POST /api/cars/{id}/provider/sync — re-apply provider data to the car
|
|
//
|
|
// Two properties are worth stating outright, because they shape the whole design:
|
|
//
|
|
// Credentials are always the *caller's*. Every call runs under the account the
|
|
// requesting user connected in Settings (resolved through the global → org → user
|
|
// cascade in integrations.go). A car shared with someone else therefore shows
|
|
// them provider data only if that vehicle is on their own manufacturer account —
|
|
// the owner's credentials are never borrowed, and never leave the server.
|
|
//
|
|
// Upstream shapes are not modelled. These are unofficial APIs whose payloads
|
|
// change without notice, so rather than hard-coding field paths this file walks
|
|
// whatever JSON comes back: findMeasure/findString locate the handful of readings
|
|
// worth promoting (odometer, fuel, battery, range), and flattenJSON turns the
|
|
// rest into dotted key/value pairs so the tab can show everything the plugin
|
|
// returned. A shape change degrades a field to "not shown" instead of breaking
|
|
// the page.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/models"
|
|
"drivervault/apiserver/internal/plugins"
|
|
)
|
|
|
|
// vehicleSource adapts one plugin that can enumerate the caller's vehicles and
|
|
// read data about them.
|
|
type vehicleSource interface {
|
|
// id is the URL segment and the value persisted on car.provider.
|
|
id() string
|
|
// label names the provider to the user — the car tab's title ("MyToyota").
|
|
label() string
|
|
// service is the upstream service behind it ("Toyota Connected Europe").
|
|
service() string
|
|
// gate resolves the caller's effective credentials for this provider from the
|
|
// integration cascade. When ok is false nothing is called and detail says, in
|
|
// one sentence, what the user has to do about it. userRaw is the caller's
|
|
// pluginSettings blob, passed in so one request reads it once.
|
|
gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (cfg map[string]string, ok bool, detail string)
|
|
// listAction is the plugin capability that enumerates vehicles.
|
|
listAction() string
|
|
// vehicles maps that capability's payload onto normalized entries.
|
|
vehicles(raw json.RawMessage) []providerVehicle
|
|
// sections are the per-vehicle capabilities the tab fetches, in display order.
|
|
sections() []providerSection
|
|
}
|
|
|
|
// vehicleSources are the registered providers, in menu order.
|
|
func vehicleSources() []vehicleSource {
|
|
return []vehicleSource{toyotaSource{}}
|
|
}
|
|
|
|
func vehicleSourceByID(id string) (vehicleSource, bool) {
|
|
for _, src := range vehicleSources() {
|
|
if src.id() == id {
|
|
return src, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// providerSection is one per-vehicle capability, rendered as a card in the tab.
|
|
// The Web App localizes the heading from ID, so nothing here is English.
|
|
type providerSection struct {
|
|
ID string // stable id, e.g. "telemetry"
|
|
Action string // the plugin capability to invoke
|
|
}
|
|
|
|
// providerField is one leaf of a provider payload, flattened to a dotted path.
|
|
// Everything a plugin returns becomes one of these, which is what lets the tab
|
|
// show a whole payload without this server modelling each upstream schema.
|
|
type providerField struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// providerVehicle is one vehicle on the caller's provider account, normalized
|
|
// into the fields a Car is built from. Raw and Fields carry the upstream object
|
|
// verbatim and flattened, so the UI can show more than we map.
|
|
type providerVehicle struct {
|
|
ID string `json:"id"` // the provider's own id (VIN, for Toyota)
|
|
VIN string `json:"vin,omitempty"`
|
|
Name string `json:"name"`
|
|
Make string `json:"make,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Year int `json:"year,omitempty"`
|
|
Registration string `json:"registration,omitempty"`
|
|
FuelType string `json:"fuelType,omitempty"`
|
|
BuildDate string `json:"buildDate,omitempty"`
|
|
FirstRegistrationDate string `json:"firstRegistrationDate,omitempty"`
|
|
ImageURL string `json:"imageUrl,omitempty"`
|
|
Fields []providerField `json:"fields,omitempty"`
|
|
Raw json.RawMessage `json:"raw,omitempty"`
|
|
|
|
// LinkedCarID is set when the caller already has a car linked to this
|
|
// vehicle, so the UI can offer to open it instead of importing it twice.
|
|
LinkedCarID string `json:"linkedCarId,omitempty"`
|
|
}
|
|
|
|
// providerMetric is a headline reading lifted out of the sections — the few
|
|
// values worth showing large. Key is a stable id the Web App localizes.
|
|
type providerMetric struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Unit string `json:"unit,omitempty"`
|
|
}
|
|
|
|
// sectionResult is one capability's outcome. A section that fails carries its
|
|
// error and the rest still render: half a snapshot beats an error page.
|
|
type sectionResult struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"` // ok | error | empty
|
|
Error string `json:"error,omitempty"`
|
|
Fields []providerField `json:"fields,omitempty"`
|
|
// Truncated reports that Fields was capped (see maxSectionFields).
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
Raw json.RawMessage `json:"raw,omitempty"`
|
|
}
|
|
|
|
// providerSnapshot is everything a provider can currently tell us about one car.
|
|
type providerSnapshot struct {
|
|
Provider string `json:"provider"`
|
|
Label string `json:"label"`
|
|
Service string `json:"service"`
|
|
VehicleID string `json:"vehicleId,omitempty"`
|
|
|
|
// Unavailable + Detail replace the payload when the caller cannot reach the
|
|
// provider at all (not connected, or this vehicle is not on their account).
|
|
Unavailable bool `json:"unavailable,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
|
|
FetchedAt string `json:"fetchedAt,omitempty"`
|
|
Vehicle *providerVehicle `json:"vehicle,omitempty"`
|
|
Metrics []providerMetric `json:"metrics,omitempty"`
|
|
Sections []sectionResult `json:"sections,omitempty"`
|
|
|
|
// SuggestedCurrentKm is the odometer the provider reports, when it differs
|
|
// from the car's stored reading — what the tab's "update odometer" offers.
|
|
SuggestedCurrentKm int `json:"suggestedCurrentKm,omitempty"`
|
|
}
|
|
|
|
// maxSectionFields caps how many flattened leaves one section returns. A
|
|
// notification history can run to hundreds of entries; the raw payload is still
|
|
// attached, so nothing is lost — only the pre-flattened list is bounded.
|
|
const maxSectionFields = 300
|
|
|
|
// ---- provider listing --------------------------------------------------------
|
|
|
|
// GET /api/vehicle-providers — the registered providers and whether the caller
|
|
// can currently use each one. Never 4xx for a provider that is merely not
|
|
// connected: the Web App shows those as "connect in Settings".
|
|
func (s *Server) handleListVehicleProviders(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
|
|
out := make([]map[string]any, 0, len(vehicleSources()))
|
|
for _, src := range vehicleSources() {
|
|
_, ok, detail := src.gate(r.Context(), s, who, userRaw)
|
|
out = append(out, map[string]any{
|
|
"id": src.id(),
|
|
"label": src.label(),
|
|
"service": src.service(),
|
|
"connected": ok,
|
|
"detail": detail,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"providers": out})
|
|
}
|
|
|
|
// resolveSource looks up the provider named in the path and gates it for the
|
|
// caller. On any failure it writes the response and returns ok=false.
|
|
//
|
|
// softGate picks how a closed gate is reported. A listing answers 200 with an
|
|
// empty list plus a reason, so the UI can say "connect this in Settings" instead
|
|
// of showing a failure; a write (import, link) answers 400, because there the
|
|
// caller asked for something that did not happen and must not read the reply as
|
|
// success.
|
|
func (s *Server) resolveSource(w http.ResponseWriter, r *http.Request, softGate bool) (vehicleSource, map[string]string, bool) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return nil, nil, false
|
|
}
|
|
src, found := vehicleSourceByID(r.PathValue("provider"))
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "unknown vehicle provider")
|
|
return nil, nil, false
|
|
}
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
|
|
if !ok {
|
|
if softGate {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"provider": src.id(), "label": src.label(),
|
|
"vehicles": []any{}, "unavailable": true, "detail": detail,
|
|
})
|
|
} else {
|
|
writeError(w, http.StatusBadRequest, detail)
|
|
}
|
|
return nil, nil, false
|
|
}
|
|
return src, cfg, true
|
|
}
|
|
|
|
// ---- vehicle listing ---------------------------------------------------------
|
|
|
|
// GET /api/vehicle-providers/{provider}/vehicles — the vehicles on the caller's
|
|
// account with that provider, normalized into importable car fields, each
|
|
// annotated with the car it is already linked to (if any).
|
|
func (s *Server) handleProviderVehicles(w http.ResponseWriter, r *http.Request) {
|
|
src, cfg, ok := s.resolveSource(w, r, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Annotate with the caller's existing links so the UI never offers to import
|
|
// the same vehicle twice.
|
|
linked := s.linkedVehicles(r.Context(), s.currentUserID(r), src.id())
|
|
for i := range vehicles {
|
|
vehicles[i].LinkedCarID = linked[vehicles[i].ID]
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"provider": src.id(),
|
|
"label": src.label(),
|
|
"service": src.service(),
|
|
"vehicles": vehicles,
|
|
})
|
|
}
|
|
|
|
// fetchProviderVehicles invokes the provider's list capability and normalizes it.
|
|
func (s *Server) fetchProviderVehicles(ctx context.Context, src vehicleSource, cfg map[string]string) ([]providerVehicle, error) {
|
|
raw, err := s.plugins.InvokeWith(ctx, src.id(), cfg, src.listAction(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return src.vehicles(raw), nil
|
|
}
|
|
|
|
// linkedVehicles maps provider vehicle id -> car id for the cars a user owns
|
|
// under one provider. Best effort: an unreachable PocketBase yields an empty map
|
|
// and the UI simply shows nothing as linked.
|
|
func (s *Server) linkedVehicles(ctx context.Context, userID, provider string) map[string]string {
|
|
out := map[string]string{}
|
|
if userID == "" || provider == "" {
|
|
return out
|
|
}
|
|
res, err := s.pb.List(ctx, colCars, url.Values{
|
|
"filter": {fmt.Sprintf("owner='%s' && provider='%s'", userID, provider)},
|
|
"perPage": {"200"},
|
|
})
|
|
if err != nil {
|
|
return out
|
|
}
|
|
var recs []carRecord
|
|
if json.Unmarshal(res.Items, &recs) != nil {
|
|
return out
|
|
}
|
|
for _, rec := range recs {
|
|
if rec.ProviderVehicleID != "" {
|
|
out[rec.ProviderVehicleID] = rec.ID
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// findVehicle picks one vehicle out of the caller's provider account by id,
|
|
// falling back to a VIN match so a client may address it either way.
|
|
func findVehicle(vehicles []providerVehicle, id string) (providerVehicle, bool) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return providerVehicle{}, false
|
|
}
|
|
for _, v := range vehicles {
|
|
if strings.EqualFold(v.ID, id) || (v.VIN != "" && strings.EqualFold(v.VIN, id)) {
|
|
return v, true
|
|
}
|
|
}
|
|
return providerVehicle{}, false
|
|
}
|
|
|
|
// ---- import ------------------------------------------------------------------
|
|
|
|
// importSelection is the caller's choice of what to pull from the provider. Each
|
|
// field is a tri-state: absent means "yes", which makes the plain request
|
|
// {"vehicleId": …} mean "fetch everything you can".
|
|
type importSelection struct {
|
|
Identity *bool `json:"identity"` // make, model, year, registration, VIN
|
|
FuelType *bool `json:"fuelType"`
|
|
Dates *bool `json:"dates"` // build / first-registration dates
|
|
Odometer *bool `json:"odometer"` // current km, from the telemetry capability
|
|
}
|
|
|
|
// resolvedSelection is an importSelection with the defaults applied.
|
|
type resolvedSelection struct{ identity, fuelType, dates, odometer bool }
|
|
|
|
func (in *importSelection) resolve() resolvedSelection {
|
|
pick := func(v *bool) bool { return v == nil || *v }
|
|
if in == nil {
|
|
return resolvedSelection{true, true, true, true}
|
|
}
|
|
return resolvedSelection{pick(in.Identity), pick(in.FuelType), pick(in.Dates), pick(in.Odometer)}
|
|
}
|
|
|
|
// POST /api/vehicle-providers/{provider}/import — create a car from one vehicle
|
|
// on the caller's provider account. Body:
|
|
//
|
|
// {vehicleId, name?, include?: {identity, fuelType, dates, odometer}}
|
|
//
|
|
// The new car is owned by the caller and linked to the provider, so its tab works
|
|
// immediately. Importing a vehicle the caller already has is refused with 409 and
|
|
// the existing car's id, so a double submit cannot duplicate a car.
|
|
func (s *Server) handleProviderImport(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
VehicleID string `json:"vehicleId"`
|
|
Name string `json:"name"`
|
|
Include *importSelection `json:"include"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
src, cfg, ok := s.resolveSource(w, r, false)
|
|
if !ok {
|
|
return
|
|
}
|
|
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
vehicle, found := findVehicle(vehicles, body.VehicleID)
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
|
|
return
|
|
}
|
|
|
|
me := s.currentUserID(r)
|
|
if existing := s.linkedVehicles(r.Context(), me, src.id())[vehicle.ID]; existing != "" {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "this vehicle is already in your garage",
|
|
"carId": existing,
|
|
})
|
|
return
|
|
}
|
|
|
|
sel := body.Include.resolve()
|
|
car := models.Car{Name: strings.TrimSpace(body.Name)}
|
|
applyVehicleToCar(&car, vehicle, sel)
|
|
if car.Name == "" {
|
|
car.Name = vehicle.Name
|
|
}
|
|
if car.Name == "" {
|
|
car.Name = src.label() + " vehicle"
|
|
}
|
|
|
|
// The odometer is not part of the vehicle list — it comes from a per-vehicle
|
|
// capability, so it costs an extra call and is only made when asked for.
|
|
var warnings []string
|
|
if sel.odometer {
|
|
if km, ok := s.providerOdometer(r.Context(), src, cfg, vehicle.ID); ok {
|
|
car.CurrentKm = km
|
|
} else {
|
|
warnings = append(warnings, "odometer")
|
|
}
|
|
}
|
|
|
|
applyCarDefaults(&car)
|
|
payload := carPayload(car)
|
|
payload["owner"] = me
|
|
for k, v := range carProviderPayload(src.id(), vehicle.ID) {
|
|
payload[k] = v
|
|
}
|
|
|
|
var rec carRecord
|
|
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
m := rec.toModel()
|
|
m.Access = accessOwner
|
|
writeJSON(w, http.StatusCreated, map[string]any{"car": m, "warnings": warnings})
|
|
}
|
|
|
|
// applyVehicleToCar copies the selected groups of a provider vehicle onto a car.
|
|
// Only non-empty provider values are written, so a field the provider does not
|
|
// report is left as it was rather than blanked.
|
|
func applyVehicleToCar(car *models.Car, v providerVehicle, sel resolvedSelection) {
|
|
set := func(dst *string, val string) {
|
|
if strings.TrimSpace(val) != "" {
|
|
*dst = val
|
|
}
|
|
}
|
|
if sel.identity {
|
|
set(&car.Make, v.Make)
|
|
set(&car.Model, v.Model)
|
|
set(&car.Registration, v.Registration)
|
|
set(&car.VIN, v.VIN)
|
|
if v.Year > 0 {
|
|
car.Year = v.Year
|
|
}
|
|
}
|
|
if sel.fuelType {
|
|
set(&car.FuelType, v.FuelType)
|
|
}
|
|
if sel.dates {
|
|
set(&car.BuildDate, v.BuildDate)
|
|
set(&car.FirstRegistrationDate, v.FirstRegistrationDate)
|
|
}
|
|
}
|
|
|
|
// providerOdometer reads the odometer for one vehicle out of whichever section
|
|
// reports it, walking the payload rather than assuming a path. Kilometres are the
|
|
// stored unit, so a reading in miles is converted.
|
|
//
|
|
// The sections go out as one batch rather than a call each: InvokeWith builds a
|
|
// fresh instance per call, which for a connector that authenticates lazily means
|
|
// a fresh login per call too. Sections are scanned in the provider's declared
|
|
// order, so the endpoint meant to carry the odometer wins over one that happens
|
|
// to mention a distance.
|
|
func (s *Server) providerOdometer(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) (int, bool) {
|
|
defs := src.sections()
|
|
calls := make([]plugins.BatchCall, 0, len(defs))
|
|
for _, sec := range defs {
|
|
calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
|
|
}
|
|
|
|
results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
for _, res := range results {
|
|
if res.Err != nil || len(res.Result) == 0 {
|
|
continue
|
|
}
|
|
var tree any
|
|
if json.Unmarshal(res.Result, &tree) != nil {
|
|
continue
|
|
}
|
|
if km, ok := odometerKm(tree); ok {
|
|
return km, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// odometerKm extracts an odometer reading in kilometres from a decoded payload.
|
|
func odometerKm(tree any) (int, bool) {
|
|
n, unit, ok := findMeasure(tree, "odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal")
|
|
if !ok || n <= 0 {
|
|
return 0, false
|
|
}
|
|
if isMiles(unit) {
|
|
n *= 1.609344
|
|
}
|
|
return int(math.Round(n)), true
|
|
}
|
|
|
|
func isMiles(unit string) bool {
|
|
u := strings.ToLower(strings.TrimSpace(unit))
|
|
return u == "mi" || u == "mile" || u == "miles" || u == "imperial"
|
|
}
|
|
|
|
// vehicleParams is the {"vin": …} params object every per-vehicle capability
|
|
// takes. The key is "vin" because that is what the plugin contract uses; the
|
|
// value is the provider's vehicle id, which for Toyota is the VIN.
|
|
func vehicleParams(vehicleID string) json.RawMessage {
|
|
b, _ := json.Marshal(map[string]string{"vin": vehicleID})
|
|
return b
|
|
}
|
|
|
|
// ---- per-car snapshot --------------------------------------------------------
|
|
|
|
// GET /api/cars/{id}/provider — everything the car's provider can currently tell
|
|
// us about it. Requires read access to the car; the provider call runs under the
|
|
// *caller's* own account, so a sharee sees data only for a vehicle that is also
|
|
// on their account (and a clear reason when it is not).
|
|
func (s *Server) handleCarProvider(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
if who == nil {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
level, rec, err := s.carAccessLevel(r.Context(), who.ID, r.PathValue("id"))
|
|
if err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
if level == accessNone {
|
|
writeError(w, http.StatusForbidden, "you do not have access to this car")
|
|
return
|
|
}
|
|
if rec.Provider == "" {
|
|
writeError(w, http.StatusNotFound, "this car is not linked to a connected service")
|
|
return
|
|
}
|
|
src, found := vehicleSourceByID(rec.Provider)
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "unknown vehicle provider")
|
|
return
|
|
}
|
|
|
|
snap := providerSnapshot{
|
|
Provider: src.id(),
|
|
Label: src.label(),
|
|
Service: src.service(),
|
|
VehicleID: rec.ProviderVehicleID,
|
|
}
|
|
|
|
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
|
cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
|
|
if !ok {
|
|
snap.Unavailable, snap.Detail = true, detail
|
|
writeJSON(w, http.StatusOK, snap)
|
|
return
|
|
}
|
|
|
|
vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
|
|
if err != nil {
|
|
snap.Unavailable, snap.Detail = true, err.Error()
|
|
writeJSON(w, http.StatusOK, snap)
|
|
return
|
|
}
|
|
vehicle, found := findVehicle(vehicles, rec.ProviderVehicleID)
|
|
if !found {
|
|
snap.Unavailable = true
|
|
snap.Detail = "this vehicle is not on your " + src.label() + " account"
|
|
writeJSON(w, http.StatusOK, snap)
|
|
return
|
|
}
|
|
snap.Vehicle = &vehicle
|
|
|
|
snap.Sections, snap.Metrics = s.fetchSections(r.Context(), src, cfg, vehicle.ID)
|
|
snap.FetchedAt = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
// Offer the provider's odometer when it is ahead of what the car has stored.
|
|
for _, m := range snap.Metrics {
|
|
if m.Key != "odometer" {
|
|
continue
|
|
}
|
|
if km, err := strconv.Atoi(m.Value); err == nil && km > rec.CurrentKm {
|
|
snap.SuggestedCurrentKm = km
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, snap)
|
|
}
|
|
|
|
// fetchSections invokes every per-vehicle capability in one batch (so a connector
|
|
// that authenticates lazily logs in once) and turns each payload into a flattened
|
|
// section plus, across all of them, the headline metrics.
|
|
func (s *Server) fetchSections(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) ([]sectionResult, []providerMetric) {
|
|
defs := src.sections()
|
|
calls := make([]plugins.BatchCall, 0, len(defs))
|
|
for _, sec := range defs {
|
|
calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
|
|
}
|
|
|
|
results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
out := make([]sectionResult, 0, len(results))
|
|
trees := make([]any, 0, len(results))
|
|
for _, res := range results {
|
|
sec := sectionResult{ID: res.ID, Status: "ok"}
|
|
switch {
|
|
case res.Err != nil:
|
|
sec.Status, sec.Error = "error", res.Err.Error()
|
|
case len(res.Result) == 0:
|
|
sec.Status = "empty"
|
|
default:
|
|
var tree any
|
|
if err := json.Unmarshal(res.Result, &tree); err != nil {
|
|
sec.Status, sec.Error = "error", "the service returned data this app could not read"
|
|
} else {
|
|
sec.Raw = res.Result
|
|
sec.Fields, sec.Truncated = flattenJSON(tree, maxSectionFields)
|
|
if len(sec.Fields) == 0 {
|
|
sec.Status = "empty"
|
|
}
|
|
trees = append(trees, tree)
|
|
}
|
|
}
|
|
out = append(out, sec)
|
|
}
|
|
return out, headlineMetrics(trees)
|
|
}
|
|
|
|
// metricSpec declares one headline reading: where to look for it and how to
|
|
// present it. The first section that reports a value wins.
|
|
type metricSpec struct {
|
|
key string
|
|
keys []string
|
|
unit string // fixed unit when the payload does not carry one
|
|
distance bool // convert an imperial reading to kilometres
|
|
}
|
|
|
|
// headlineMetricSpecs are the measured readings, in the order they are reported
|
|
// when a car has no arrangement of its own. Package-level so the set of keys a
|
|
// car's arrangement may name (arrangeableCarMetrics, in cars.go) is derived from
|
|
// it and the two cannot drift apart.
|
|
var headlineMetricSpecs = []metricSpec{
|
|
{key: "odometer", keys: []string{"odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal"}, unit: "km", distance: true},
|
|
{key: "fuelLevel", keys: []string{"fuelLevel", "fuelPercentage", "fuelRemainingPercent"}, unit: "%"},
|
|
{key: "fuelRange", keys: []string{"fuelRange", "rangeRemaining", "drivingRange", "fuelRangeTotal"}, unit: "km", distance: true},
|
|
{key: "batteryLevel", keys: []string{"batteryLevel", "chargeRemainingAmount", "stateOfCharge", "socLevel"}, unit: "%"},
|
|
{key: "evRange", keys: []string{"evRange", "electricRange", "batteryRange"}, unit: "km", distance: true},
|
|
// Range with the climate control running, which Toyota reports beside the
|
|
// plain one. Its own reading rather than a fallback for evRange: the two
|
|
// are different numbers and the gap between them is the point — a driver
|
|
// deciding whether to run the A/C wants to see both.
|
|
{key: "evRangeWithAc", keys: []string{"evRangeWithAc"}, unit: "km", distance: true},
|
|
}
|
|
|
|
// 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)
|
|
}
|