From 358ee68f94cdb596274cbd13a45e9d203077ab88 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:13:11 +0200 Subject: [PATCH] Cars: create a car from a manufacturer service, with a per-car data tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- API Server/README.md | 37 +- API Server/internal/api/integrations.go | 38 +- API Server/internal/api/records.go | 19 +- API Server/internal/api/server.go | 17 + API Server/internal/api/vehicleproviders.go | 1032 +++++++++++++++++ .../internal/api/vehicleproviders_test.go | 625 ++++++++++ .../internal/api/vehicleproviders_toyota.go | 243 ++++ API Server/internal/bootstrap/schema.go | 5 + API Server/internal/models/models.go | 8 + API Server/internal/plugins/README.md | 23 +- API Server/internal/plugins/manager.go | 57 + API Server/scripts/setup-pocketbase.mjs | 6 + README.md | 4 + Web App/README.md | 12 +- Web App/web/src/api.js | 29 + Web App/web/src/components/CarImportModal.vue | 217 ++++ Web App/web/src/components/ProviderPanel.vue | 342 ++++++ Web App/web/src/i18n/da.json | 70 ++ Web App/web/src/i18n/en.json | 70 ++ Web App/web/src/i18n/pl.json | 70 ++ Web App/web/src/lib/format.js | 12 + Web App/web/src/views/CarDetail.vue | 46 +- Web App/web/src/views/Dashboard.vue | 44 +- 23 files changed, 2983 insertions(+), 43 deletions(-) create mode 100644 API Server/internal/api/vehicleproviders.go create mode 100644 API Server/internal/api/vehicleproviders_test.go create mode 100644 API Server/internal/api/vehicleproviders_toyota.go create mode 100644 Web App/web/src/components/CarImportModal.vue create mode 100644 Web App/web/src/components/ProviderPanel.vue diff --git a/API Server/README.md b/API Server/README.md index c3d9c64..2fe37fa 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -165,9 +165,16 @@ DELETE /api/integrations/anker-solix/chargers/{sn}/control/token POST /api/integrations/anker-solix/chargers/{sn}/{action} GET /ocpp/{serial} # charger dials in here (OCPP Basic auth, not bearer) +# vehicle providers — create a car from a manufacturer service; per-car provider tab +GET /api/vehicle-providers +GET /api/vehicle-providers/{provider}/vehicles +POST /api/vehicle-providers/{provider}/import + # cars + sharing GET /api/cars POST /api/cars GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id} +GET /api/cars/{id}/provider POST /api/cars/{id}/provider +POST /api/cars/{id}/provider/sync GET /api/cars/{id}/service-records GET /api/cars/{id}/technical-checks GET /api/cars/{id}/parts GET /api/cars/{id}/fuel-entries GET /api/cars/{id}/fuel-stats GET /api/cars/{id}/maintenance GET /api/cars/{id}/documents GET /api/cars/{id}/reminders @@ -190,7 +197,35 @@ annotated with an `access` field. The per-record list endpoints also accept a > **Gotcha:** `updateCar` rewrites **all** car columns from the payload, so a > `PATCH /api/cars/{id}` must send the **full** car object — omitted spec fields > get blanked. (The phone's odometer quick-edit sends the whole car for this -> reason.) +> reason.) The two exceptions are `owner` and the provider link (`provider`, +> `provider_vehicle_id`), which `carPayload` deliberately leaves out so an +> ordinary edit can neither reassign the car nor break its connected service. + +### Vehicle providers + +`internal/api/vehicleproviders.go` turns a manufacturer-service plugin into a car +you can create from your own account with that service, plus a per-car tab showing +everything the service currently knows about it. Toyota (MyToyota) is the first +provider; adding the next one means writing a `vehicleSource` adapter and +appending it to `vehicleSources()` — no new endpoints and no Web App changes. + +Two properties shape the design: + +- **Credentials are always the caller's.** Every provider call resolves through + the same global → org → user cascade as the integration settings, so a car + shared with someone else shows them provider data only when that vehicle is on + *their* manufacturer account. The owner's credentials are never borrowed. +- **Upstream shapes are not modelled.** These are unofficial APIs. Rather than + hard-coding field paths, the layer searches payloads by key name for the handful + of readings worth promoting (odometer, fuel, battery, range) and flattens the + rest to dotted key/value pairs, shipping the raw payload alongside. A renamed + field costs one blank value instead of a broken page. + +`POST .../import` takes `{vehicleId, name?, include?}`, where `include` selects +which groups to pull (`identity`, `fuelType`, `dates`, `odometer`). Omitting it +means "everything available". `POST /api/cars/{id}/provider/sync` takes the same +selection, and only ever moves the odometer forward — a reading that appears to go +backwards is a stale provider, not a car driven in reverse. ## The panel (`/`) diff --git a/API Server/internal/api/integrations.go b/API Server/internal/api/integrations.go index cf3df41..dd2abf6 100644 --- a/API Server/internal/api/integrations.go +++ b/API Server/internal/api/integrations.go @@ -461,11 +461,16 @@ func (s *Server) handleToyotaHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"health": h}) } -// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles, fetched -// server-side under their resolved credentials. Gated by the same switches as -// the settings view (global master, org gate, personal opt-in, credentials -// present); when any gate is off it returns 200 with an empty list plus a -// reason, so the UI can degrade quietly rather than error. +// GET /api/integrations/toyota/vehicles — the caller's Toyota vehicles as the +// upstream returned them, fetched server-side under their resolved credentials. +// Gated by the same switches as the settings view (global master, org gate, +// personal opt-in, credentials present); when any gate is off it returns 200 with +// an empty list plus a reason, so the UI can degrade quietly rather than error. +// +// The gate and its wording live in toyotaSource (vehicleproviders_toyota.go), so +// this endpoint and the generic vehicle-provider endpoints cannot drift apart. +// This one stays because it relays the raw payload; /api/vehicle-providers/ +// toyota/vehicles returns the normalized, importable shape. func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { @@ -473,31 +478,12 @@ func (s *Server) handleToyotaVehicles(w http.ResponseWriter, r *http.Request) { return } userRaw := s.userPluginSettings(r.Context(), who.ID) - res := s.resolveToyota(r.Context(), who, userRaw) - - unavailable := func(detail string) { + cfg, ok, detail := toyotaSource{}.gate(r.Context(), s, who, userRaw) + if !ok { writeJSON(w, http.StatusOK, map[string]any{"vehicles": []any{}, "unavailable": true, "detail": detail}) - } - switch { - case !res.available: - unavailable("The Toyota integration is disabled by the administrator") - return - case !res.orgEnabled: - unavailable("The Toyota integration is disabled for your organization") - return - case !res.enabled: - unavailable("Enable the Toyota integration in Settings to load your vehicles") - return - case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "": - unavailable("Enter your MyToyota email and password to connect") return } - cfg := map[string]string{ - "username": res.eff.Username, - "password": res.eff.Password, - "brand": res.eff.Brand, - } raw, err := s.plugins.InvokeWith(r.Context(), toyotaPlugin, cfg, "vehicles", nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index 0f57b4a..ef12e9b 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -61,6 +61,8 @@ type carRecord struct { FuelType string `json:"fuel_type"` BuildDate string `json:"build_date"` FirstRegistrationDate string `json:"first_registration_date"` + Provider string `json:"provider"` + ProviderVehicleID string `json:"provider_vehicle_id"` Owner string `json:"owner"` Created string `json:"created"` Updated string `json:"updated"` @@ -88,13 +90,18 @@ func (rec carRecord) toModel() models.Car { FuelType: rec.FuelType, BuildDate: rec.BuildDate, FirstRegistrationDate: rec.FirstRegistrationDate, + Provider: rec.Provider, + ProviderVehicleID: rec.ProviderVehicleID, Owner: rec.Owner, Created: rec.Created, Updated: rec.Updated, } } -// carPayload builds the write payload for create/update from a domain Car. +// carPayload builds the write payload for create/update from a domain Car. It +// deliberately omits owner and the provider link: a car edit must not reassign +// ownership, and it must not touch the connected-service link either (that is +// carProviderPayload's job, reached only through the provider endpoints). func carPayload(c models.Car) map[string]any { return map[string]any{ "name": c.Name, @@ -119,6 +126,16 @@ func carPayload(c models.Car) map[string]any { } } +// carProviderPayload is the connected-service link on its own, so linking and +// unlinking is a one-field write that leaves the rest of the car alone. An empty +// provider clears both fields (unlink). +func carProviderPayload(provider, vehicleID string) map[string]any { + if provider == "" { + return map[string]any{"provider": "", "provider_vehicle_id": ""} + } + return map[string]any{"provider": provider, "provider_vehicle_id": vehicleID} +} + // --- service records --- type serviceRecord struct { diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index f9bf25a..6742d93 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -51,9 +51,16 @@ // POST /api/integrations/anker-solix/health // GET /api/integrations/anker-solix/chargers // +// # vehicle providers (create a car from a manufacturer service; per-car tab) +// GET /api/vehicle-providers +// GET /api/vehicle-providers/{provider}/vehicles +// POST /api/vehicle-providers/{provider}/import +// // # cars, service records, parts, shares // GET /api/cars POST /api/cars // GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id} +// GET /api/cars/{id}/provider POST /api/cars/{id}/provider +// POST /api/cars/{id}/provider/sync // GET /api/cars/{id}/service-records // GET /api/cars/{id}/parts // GET /api/cars/{id}/shares POST /api/cars/{id}/shares @@ -330,6 +337,13 @@ func (s *Server) Handler() http.Handler { // OCPP Basic auth (serial + per-charger control token) instead. mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect) + // Vehicle providers — manufacturer services a car can be created from, and + // the per-car provider tab. Generic over the registered providers; see + // vehicleproviders.go. + mux.HandleFunc("GET /api/vehicle-providers", s.handleListVehicleProviders) + mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles) + mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport) + // Cars + sharing. mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("POST /api/cars", s.createCar) @@ -344,6 +358,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance) mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments) mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders) + mux.HandleFunc("GET /api/cars/{id}/provider", s.handleCarProvider) + mux.HandleFunc("POST /api/cars/{id}/provider", s.handleLinkCarProvider) + mux.HandleFunc("POST /api/cars/{id}/provider/sync", s.handleSyncCarProvider) mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares) mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare) mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare) diff --git a/API Server/internal/api/vehicleproviders.go b/API Server/internal/api/vehicleproviders.go new file mode 100644 index 0000000..8e46e87 --- /dev/null +++ b/API Server/internal/api/vehicleproviders.go @@ -0,0 +1,1032 @@ +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) +} diff --git a/API Server/internal/api/vehicleproviders_test.go b/API Server/internal/api/vehicleproviders_test.go new file mode 100644 index 0000000..c45f49c --- /dev/null +++ b/API Server/internal/api/vehicleproviders_test.go @@ -0,0 +1,625 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "drivervault/apiserver/internal/config" + "drivervault/apiserver/internal/pb" +) + +// The vehicle-provider layer deliberately searches payloads by key name instead +// of by path, so these tests pin the behaviour that makes that safe: top-level +// keys win over nested ones, the three numeric shapes are all understood, and an +// unrecognized value is dropped rather than guessed at. + +func decode(t *testing.T, s string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + t.Fatalf("bad test JSON: %v", err) + } + return v +} + +func TestFindNodePrefersShallowKeys(t *testing.T) { + // "model" appears at the top level and again inside a nested dealer record. + // The shallow one has to win, or an import picks up the dealer's data. + tree := decode(t, `{ + "model": "Yaris", + "dealer": {"model": "Corolla", "name": "City Toyota"} + }`) + if got := findString(tree, "model"); got != "Yaris" { + t.Fatalf("model = %q, want Yaris", got) + } +} + +func TestFindNodeDescendsWhenAbsentAtTop(t *testing.T) { + tree := decode(t, `{"payload": {"vehicle": {"vin": "VIN123"}}}`) + if got := findString(tree, "vin"); got != "VIN123" { + t.Fatalf("vin = %q, want VIN123", got) + } +} + +func TestFindNodeMissingKey(t *testing.T) { + tree := decode(t, `{"a": 1}`) + if got := findString(tree, "vin"); got != "" { + t.Fatalf("expected empty string for a missing key, got %q", got) + } + if _, _, ok := findMeasure(tree, "odometer"); ok { + t.Error("expected findMeasure to report not-found") + } +} + +func TestFindMeasureShapes(t *testing.T) { + cases := []struct { + name string + json string + value float64 + unit string + }{ + {"bare number", `{"fuelLevel": 62}`, 62, ""}, + {"numeric string", `{"fuelLevel": "62"}`, 62, ""}, + {"value+unit object", `{"odometer": {"value": 270185, "unit": "km"}}`, 270185, "km"}, + {"snake case key", `{"fuel_level": 40}`, 40, ""}, + {"screaming key", `{"FUEL-LEVEL": 40}`, 40, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n, unit, ok := findMeasure(decode(t, tc.json), "fuelLevel", "odometer") + if !ok { + t.Fatal("expected a measure") + } + if n != tc.value { + t.Errorf("value = %v, want %v", n, tc.value) + } + if unit != tc.unit { + t.Errorf("unit = %q, want %q", unit, tc.unit) + } + }) + } +} + +func TestOdometerKmConvertsMiles(t *testing.T) { + km, ok := odometerKm(decode(t, `{"odometer": {"value": 100, "unit": "mi"}}`)) + if !ok { + t.Fatal("expected an odometer reading") + } + if km != 161 { + t.Fatalf("km = %d, want 161 (100 mi)", km) + } + + km, ok = odometerKm(decode(t, `{"odometer": {"value": 270185, "unit": "km"}}`)) + if !ok || km != 270185 { + t.Fatalf("km = %d (ok=%v), want 270185", km, ok) + } + + // A zero or absent odometer is "no reading", not a reading of zero — it must + // never overwrite a real stored value with 0. + if _, ok := odometerKm(decode(t, `{"odometer": {"value": 0, "unit": "km"}}`)); ok { + t.Error("a zero odometer should not count as a reading") + } +} + +func TestFlattenJSON(t *testing.T) { + fields, truncated := flattenJSON(decode(t, `{ + "payload": { + "odometer": {"value": 270185, "unit": "km"}, + "nothing": null, + "blank": " ", + "flag": true, + "warnings": ["oil", "tyre"] + } + }`), 100) + if truncated { + t.Error("should not be truncated") + } + + got := map[string]string{} + for _, f := range fields { + got[f.Key] = f.Value + } + want := map[string]string{ + "payload.odometer.value": "270185", + "payload.odometer.unit": "km", + "payload.flag": "true", + "payload.warnings[0]": "oil", + "payload.warnings[1]": "tyre", + } + for k, v := range want { + if got[k] != v { + t.Errorf("field %q = %q, want %q", k, got[k], v) + } + } + // Nulls and whitespace-only strings are omitted: they are the provider having + // no value, and rendering them as rows would be noise. + for _, k := range []string{"payload.nothing", "payload.blank"} { + if _, present := got[k]; present { + t.Errorf("field %q should have been omitted", k) + } + } +} + +func TestFlattenJSONTruncates(t *testing.T) { + fields, truncated := flattenJSON(decode(t, `{"a":1,"b":2,"c":3,"d":4}`), 2) + if !truncated { + t.Error("expected truncated=true") + } + if len(fields) != 2 { + t.Fatalf("len(fields) = %d, want 2", len(fields)) + } +} + +func TestHeadlineMetrics(t *testing.T) { + trees := []any{ + decode(t, `{"payload": {"odometer": {"value": 270185, "unit": "km"}, "fuelLevel": 62}}`), + decode(t, `{"payload": {"batteryLevel": 80, "chargingStatus": "charging"}}`), + decode(t, `{"payload": {"vehicleLocation": {"latitude": 52.2297, "longitude": 21.0122}}}`), + } + got := map[string]providerMetric{} + for _, m := range headlineMetrics(trees) { + got[m.Key] = m + } + + if got["odometer"].Value != "270185" || got["odometer"].Unit != "km" { + t.Errorf("odometer = %+v", got["odometer"]) + } + if got["fuelLevel"].Value != "62" || got["fuelLevel"].Unit != "%" { + t.Errorf("fuelLevel = %+v", got["fuelLevel"]) + } + if got["batteryLevel"].Value != "80" { + t.Errorf("batteryLevel = %+v", got["batteryLevel"]) + } + if got["chargingStatus"].Value != "charging" { + t.Errorf("chargingStatus = %+v", got["chargingStatus"]) + } + if got["location"].Value != "52.22970, 21.01220" { + t.Errorf("location = %+v", got["location"]) + } +} + +func TestNormalizeProviderFuelType(t *testing.T) { + cases := map[string]string{ + "HV": "hybrid", + "hybrid": "hybrid", + "PETROL_HYBRID": "hybrid", // hybrid wins over the petrol substring + "phev": "hybrid", + "Diesel": "diesel", + "BEV": "electric", + "Battery Electric": "electric", + "gasoline": "petrol", + "FCEV": "hydrogen", + "": "", + "something else": "", + } + for in, want := range cases { + if got := normalizeProviderFuelType(in); got != want { + t.Errorf("normalizeProviderFuelType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestISODateOnly(t *testing.T) { + cases := map[string]string{ + "2015-06-01": "2015-06-01", + "2015-06-01T00:00:00Z": "2015-06-01", + // Ambiguous formats are dropped rather than guessed: 01/06/2015 is June + // or January depending on who wrote it. + "01/06/2015": "", + "2015-06": "", + "": "", + "not a date": "", + } + for in, want := range cases { + if got := isoDateOnly(in); got != want { + t.Errorf("isoDateOnly(%q) = %q, want %q", in, got, want) + } + } +} + +func TestToyotaVehiclesNormalizes(t *testing.T) { + raw := json.RawMessage(`{"payload": [{ + "vin": "JTDKB20U000000001", + "alias": "Daily driver", + "brand": "T", + "modelName": "Yaris", + "modelYear": 2015, + "licensePlate": "ABC 1234", + "fuelType": "HV", + "firstRegistrationDate": "2015-06-01T00:00:00Z", + "imageUrl": "https://example.invalid/yaris.png" + }]}`) + + got := toyotaSource{}.vehicles(raw) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + v := got[0] + if v.ID != "JTDKB20U000000001" || v.VIN != v.ID { + t.Errorf("id/vin = %q/%q", v.ID, v.VIN) + } + if v.Name != "Daily driver" { + t.Errorf("name = %q, want the owner's alias", v.Name) + } + if v.Make != "Toyota" || v.Model != "Yaris" || v.Year != 2015 { + t.Errorf("make/model/year = %q/%q/%d", v.Make, v.Model, v.Year) + } + if v.Registration != "ABC 1234" { + t.Errorf("registration = %q", v.Registration) + } + if v.FuelType != "hybrid" { + t.Errorf("fuelType = %q, want hybrid", v.FuelType) + } + if v.FirstRegistrationDate != "2015-06-01" { + t.Errorf("firstRegistrationDate = %q", v.FirstRegistrationDate) + } + if len(v.Fields) == 0 || len(v.Raw) == 0 { + t.Error("the whole upstream object should still reach the UI via Fields/Raw") + } +} + +func TestToyotaVehiclesFallbacks(t *testing.T) { + // No alias and no VIN: the name falls back to make + model and the id to + // whatever other identifier the payload carries. + got := toyotaSource{}.vehicles(json.RawMessage(`[{"guid": "abc", "brand": "L", "modelName": "IS 300h"}]`)) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + if got[0].ID != "abc" { + t.Errorf("id = %q, want abc", got[0].ID) + } + if got[0].Name != "Lexus IS 300h" { + t.Errorf("name = %q, want Lexus IS 300h", got[0].Name) + } + + // A vehicle with nothing to address it by is skipped, not imported blind. + anonymous := toyotaSource{}.vehicles(json.RawMessage(`{"payload": [{"colour": "blue"}]}`)) + if len(anonymous) != 0 { + t.Errorf("expected an unidentifiable vehicle to be skipped, got %d", len(anonymous)) + } + malformed := toyotaSource{}.vehicles(json.RawMessage(`not json`)) + if len(malformed) != 0 { + t.Errorf("expected no vehicles from malformed JSON, got %d", len(malformed)) + } +} + +func TestImportSelectionDefaultsToEverything(t *testing.T) { + // The plain request {"vehicleId": …} must mean "fetch everything you can". + all := (*importSelection)(nil).resolve() + if !all.identity || !all.fuelType || !all.dates || !all.odometer { + t.Fatalf("nil selection = %+v, want everything on", all) + } + + no := false + partial := (&importSelection{Odometer: &no}).resolve() + if partial.odometer { + t.Error("odometer should be off") + } + if !partial.identity || !partial.fuelType || !partial.dates { + t.Errorf("unspecified groups should stay on, got %+v", partial) + } +} + +func TestApplyVehicleToCarSkipsBlanks(t *testing.T) { + car := carRecord{Make: "Toyota", Model: "Yaris", Registration: "OLD 111"}.toModel() + // The provider reports a model but no registration: the stored plate must + // survive rather than being blanked by an absent upstream field. + applyVehicleToCar(&car, providerVehicle{Model: "Yaris Hybrid"}, resolvedSelection{identity: true}) + if car.Model != "Yaris Hybrid" { + t.Errorf("model = %q, want the provider value", car.Model) + } + if car.Registration != "OLD 111" { + t.Errorf("registration = %q, want the stored value kept", car.Registration) + } + + // A group that was not selected is not written at all. + car2 := carRecord{FuelType: "petrol"}.toModel() + applyVehicleToCar(&car2, providerVehicle{FuelType: "hybrid"}, resolvedSelection{identity: true}) + if car2.FuelType != "petrol" { + t.Errorf("fuelType = %q, want petrol (group not selected)", car2.FuelType) + } +} + +func TestFindVehicleMatchesIDOrVIN(t *testing.T) { + vehicles := []providerVehicle{{ID: "abc", VIN: "VIN1"}, {ID: "VIN2", VIN: "VIN2"}} + if v, ok := findVehicle(vehicles, "vin1"); !ok || v.ID != "abc" { + t.Error("expected a case-insensitive VIN match") + } + if v, ok := findVehicle(vehicles, "VIN2"); !ok || v.ID != "VIN2" { + t.Error("expected an id match") + } + if _, ok := findVehicle(vehicles, ""); ok { + t.Error("an empty id must not match the first vehicle") + } + if _, ok := findVehicle(vehicles, "nope"); ok { + t.Error("unexpected match") + } +} + +func TestVehicleSourceRegistry(t *testing.T) { + src, ok := vehicleSourceByID("toyota") + if !ok { + t.Fatal("toyota should be a registered vehicle source") + } + if src.label() != "MyToyota" { + t.Errorf("label = %q, want MyToyota", src.label()) + } + if len(src.sections()) == 0 { + t.Error("expected per-vehicle sections") + } + if _, ok := vehicleSourceByID("ford"); ok { + t.Error("unregistered provider should not resolve") + } +} + +// ---- routing + gating, through the real Handler ------------------------------ +// +// These go through the whole middleware chain against a stand-in PocketBase, with +// the Toyota plugin left disabled. That covers two things a unit test of the +// helpers cannot: that every route is actually registered under the pattern the +// clients call, and that a closed gate is reported the way each kind of endpoint +// needs — a listing answers 200 with a reason so the UI can say "connect this in +// Settings", while a write answers 4xx so a caller can never read the reply as a +// successful import. +// +// Note an unauthenticated probe proves neither: withAuth wraps the mux, so every +// path — registered or not — answers 401 before routing happens. Hence the bearer. + +const provCarID = "car-1" + +// fakeProviderPB answers the identity, user-record and car-record calls these +// endpoints make, and records the last car PATCH so a test can assert what was +// written. Nothing else is needed: the caller owns the car, so the access check +// never consults car_shares. +type fakeProviderPB struct { + carProvider, carVehicleID string + mu sync.Mutex + lastCarPatch map[string]any +} + +func (f *fakeProviderPB) patch() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.lastCarPatch +} + +func (f *fakeProviderPB) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"token": "svc-token"}) + }) + mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + writeJSON(w, 401, map[string]any{}) + return + } + writeJSON(w, 200, map[string]any{"record": map[string]any{ + "id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "", + }}) + }) + mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"id": e2eUserID, "email": e2eUserMail, "role": "user"}) + }) + mux.HandleFunc("GET /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, f.carRecord()) + }) + mux.HandleFunc("PATCH /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) { + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + f.mu.Lock() + f.lastCarPatch = in + // Apply the provider columns so a follow-up read reflects the write. + if v, ok := in["provider"].(string); ok { + f.carProvider = v + } + if v, ok := in["provider_vehicle_id"].(string); ok { + f.carVehicleID = v + } + f.mu.Unlock() + writeJSON(w, 200, f.carRecord()) + }) + return mux +} + +func (f *fakeProviderPB) carRecord() map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return map[string]any{ + "id": provCarID, "name": "Yaris", "owner": e2eUserID, + "provider": f.carProvider, "provider_vehicle_id": f.carVehicleID, + } +} + +// newProviderTestServer wires a Server against the fake PocketBase with the +// Toyota plugin present but disabled — the "administrator turned it off" state, +// which closes the gate without any network call to Toyota. +func newProviderTestServer(t *testing.T, fake *fakeProviderPB) *httptest.Server { + t.Helper() + pbSrv := httptest.NewServer(fake.handler()) + t.Cleanup(pbSrv.Close) + + pluginsFile := filepath.Join(t.TempDir(), "plugins.json") + if err := os.WriteFile(pluginsFile, []byte(`{"toyota":{"enabled":false}}`), 0o600); err != nil { + t.Fatal(err) + } + s := New(config.Config{UsersCollection: "users", PluginsFile: pluginsFile}, + pb.New(pbSrv.URL, "admin@test.local", "pw")) + if err := s.plugins.Load(); err != nil { + t.Fatalf("load plugins: %v", err) + } + srv := httptest.NewServer(s.Handler()) + t.Cleanup(srv.Close) + return srv +} + +// call makes an authenticated request and returns the status and decoded body. +func call(t *testing.T, srv *httptest.Server, method, path, body string) (int, map[string]any) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, srv.URL+path, rdr) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+e2eBearer) + req.Header.Set("Content-Type", "application/json") + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + out := map[string]any{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +func TestVehicleProviderListing(t *testing.T) { + srv := newProviderTestServer(t, &fakeProviderPB{}) + + status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + list, _ := body["providers"].([]any) + if len(list) == 0 { + t.Fatal("expected at least the Toyota provider") + } + first, _ := list[0].(map[string]any) + if first["id"] != "toyota" || first["label"] != "MyToyota" { + t.Errorf("provider = %+v, want toyota/MyToyota", first) + } + // Disabled globally: reported as not connected, with a reason to show the user + // rather than an error. + if first["connected"] != false { + t.Errorf("connected = %v, want false", first["connected"]) + } + if first["detail"] == "" { + t.Error("a disconnected provider must explain why") + } +} + +func TestVehicleProviderGateSoftOnListHardOnWrite(t *testing.T) { + srv := newProviderTestServer(t, &fakeProviderPB{}) + + // Listing: 200 + unavailable, so the UI degrades to "connect in Settings". + status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers/toyota/vehicles", "") + if status != http.StatusOK { + t.Errorf("vehicles status = %d, want 200", status) + } + if body["unavailable"] != true || body["detail"] == "" { + t.Errorf("vehicles body = %+v, want unavailable with a reason", body) + } + + // Import: 400, so a client can never mistake the reply for a created car. + status, body = call(t, srv, http.MethodPost, "/api/vehicle-providers/toyota/import", `{"vehicleId":"VIN1"}`) + if status != http.StatusBadRequest { + t.Errorf("import status = %d, want 400", status) + } + if body["error"] == "" { + t.Error("a refused import must say why") + } + if _, created := body["car"]; created { + t.Error("a refused import must not return a car") + } +} + +func TestVehicleProviderUnknownProvider(t *testing.T) { + srv := newProviderTestServer(t, &fakeProviderPB{}) + if status, _ := call(t, srv, http.MethodGet, "/api/vehicle-providers/ford/vehicles", ""); status != http.StatusNotFound { + t.Errorf("status = %d, want 404 for an unregistered provider", status) + } +} + +func TestCarProviderRoutesOnUnlinkedCar(t *testing.T) { + srv := newProviderTestServer(t, &fakeProviderPB{}) // car has no provider link + + // Reaching these messages proves the routes are registered and the car's + // access check ran — a missing route would 404 from the mux with no message. + status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "") + if status != http.StatusNotFound { + t.Errorf("snapshot status = %d, want 404", status) + } + if !strings.Contains(toStr(body["error"]), "not linked") { + t.Errorf("snapshot error = %v, want the not-linked message", body["error"]) + } + + status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider/sync", `{}`) + if status != http.StatusBadRequest { + t.Errorf("sync status = %d, want 400", status) + } + if !strings.Contains(toStr(body["error"]), "not linked") { + t.Errorf("sync error = %v, want the not-linked message", body["error"]) + } + + // Linking needs the vehicle to be on the caller's account, so a closed gate + // refuses it outright rather than storing a link that could never be read. + status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider", + `{"provider":"toyota","vehicleId":"VIN1"}`) + if status != http.StatusBadRequest { + t.Errorf("link status = %d, want 400 while the provider is unusable", status) + } +} + +func TestCarProviderUnlinkWorksWithProviderUnreachable(t *testing.T) { + // Unlinking must not depend on the provider being reachable — otherwise a car + // could stay stuck to an account the user has since removed. It writes only + // the two link columns, leaving the rest of the car alone. + fake := &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"} + srv := newProviderTestServer(t, fake) + + status, body := call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider", `{"provider":"","vehicleId":""}`) + if status != http.StatusOK { + t.Fatalf("unlink status = %d (%v), want 200", status, body["error"]) + } + if body["provider"] != nil { + t.Errorf("returned car still reports provider %v", body["provider"]) + } + + patch := fake.patch() + if patch["provider"] != "" || patch["provider_vehicle_id"] != "" { + t.Errorf("patch = %+v, want both link columns cleared", patch) + } + if len(patch) != 2 { + t.Errorf("patch touched %d columns (%+v); unlinking must write only the link", len(patch), patch) + } +} + +func TestCarProviderSnapshotGateClosed(t *testing.T) { + // A linked car whose provider the caller cannot currently use: the snapshot is + // still a 200 with a reason, so the tab renders an explanation instead of an + // error page. + srv := newProviderTestServer(t, &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"}) + + status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if body["provider"] != "toyota" || body["label"] != "MyToyota" { + t.Errorf("body = %+v, want the provider identified", body) + } + if body["unavailable"] != true || body["detail"] == "" { + t.Errorf("body = %+v, want unavailable with a reason", body) + } + if _, fetched := body["sections"]; fetched { + t.Error("no sections should be fetched when the gate is closed") + } +} + +func TestFormatNumber(t *testing.T) { + cases := map[float64]string{270185: "270185", 62.5: "62.5", 0: "0", -3: "-3"} + for in, want := range cases { + if got := formatNumber(in); got != want { + t.Errorf("formatNumber(%v) = %q, want %q", in, got, want) + } + } +} diff --git a/API Server/internal/api/vehicleproviders_toyota.go b/API Server/internal/api/vehicleproviders_toyota.go new file mode 100644 index 0000000..afc4713 --- /dev/null +++ b/API Server/internal/api/vehicleproviders_toyota.go @@ -0,0 +1,243 @@ +package api + +// toyotaSource is the first vehicleSource: Toyota Connected Europe, the backend +// behind the MyToyota app (see internal/plugins/builtin/toyota). It contributes +// nothing but wiring — credentials come from the cascade already in +// integrations.go, and the payload walking lives in vehicleproviders.go — which +// is the shape the next manufacturer's adapter should copy. + +import ( + "context" + "encoding/json" + "strings" +) + +// toyotaLabel is what the user sees: the app they know the account by, not the +// corporate service name behind it. It titles the car's provider tab. +const toyotaLabel = "MyToyota" + +type toyotaSource struct{} + +func (toyotaSource) id() string { return toyotaPlugin } +func (toyotaSource) label() string { return toyotaLabel } +func (toyotaSource) service() string { return "Toyota Connected Europe" } + +func (toyotaSource) listAction() string { return "vehicles" } + +// sections are the plugin's per-VIN read-only capabilities, in the order the tab +// shows them: the live readings first, then the history. +func (toyotaSource) sections() []providerSection { + return []providerSection{ + {ID: "telemetry", Action: "telemetry"}, + {ID: "electric", Action: "electric"}, + {ID: "status", Action: "status"}, + {ID: "health", Action: "health"}, + {ID: "location", Action: "location"}, + {ID: "serviceHistory", Action: "service-history"}, + {ID: "notifications", Action: "notifications"}, + } +} + +// gate resolves the caller's MyToyota credentials through the global → org → user +// cascade and reports what is missing when they cannot be used. It is the single +// place those messages are written: handleToyotaVehicles calls it too. +func (toyotaSource) gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (map[string]string, bool, string) { + res := s.resolveToyota(ctx, who, userRaw) + switch { + case !res.available: + return nil, false, "The Toyota integration is disabled by the administrator" + case !res.orgEnabled: + return nil, false, "The Toyota integration is disabled for your organization" + case !res.enabled: + return nil, false, "Enable the Toyota integration in Settings to load your vehicles" + case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "": + return nil, false, "Enter your MyToyota email and password to connect" + } + return map[string]string{ + "username": res.eff.Username, + "password": res.eff.Password, + "brand": res.eff.Brand, + }, true, "" +} + +// vehicles normalizes the /v2/vehicle/guid payload. Every field is looked up by +// name rather than by path (see findString in vehicleproviders.go), because this +// is an unofficial API: a renamed or moved key costs one blank field instead of a +// broken import, and the whole object still reaches the UI via Fields/Raw. +func (toyotaSource) vehicles(raw json.RawMessage) []providerVehicle { + objects := vehicleObjects(raw) + out := make([]providerVehicle, 0, len(objects)) + + for _, obj := range objects { + vin := findString(obj, "vin") + id := vin + if id == "" { + id = findString(obj, "vehicleId", "guid", "id") + } + if id == "" { + continue // nothing stable to address it by; skip rather than guess + } + + v := providerVehicle{ + ID: id, + VIN: vin, + Make: toyotaMake(findString(obj, "brand", "brandName", "make")), + Model: findString(obj, "modelName", "modelDescription", "carModelName", "carLineName", "model"), + Registration: findString(obj, "licensePlate", "registrationNumber", "plateNumber", "licencePlate"), + FuelType: normalizeProviderFuelType(findString(obj, "fuelType", "fuel", "engineType", "powerTrain", "drivetrain")), + BuildDate: isoDateOnly(findString(obj, "productionDate", "manufacturingDate", "buildDate")), + FirstRegistrationDate: isoDateOnly(findString(obj, "firstRegistrationDate", "initialRegistrationDate", "registrationDate")), + ImageURL: findString(obj, "imageUrl", "carImageUrl", "image", "picture"), + } + if year, ok := findInt(obj, "modelYear", "productionYear", "year"); ok && year > 1900 && year < 2200 { + v.Year = year + } + v.Name = vehicleDisplayName(obj, v) + + if b, err := json.Marshal(obj); err == nil { + v.Raw = b + v.Fields, _ = flattenJSON(obj, maxSectionFields) + } + out = append(out, v) + } + return out +} + +// toyotaMake expands the one-letter brand code the app uses on the wire ("T"/"L") +// and otherwise passes the reported brand through in title case. +func toyotaMake(brand string) string { + switch strings.ToUpper(strings.TrimSpace(brand)) { + case "T", "TOYOTA", "": + return "Toyota" + case "L", "LEXUS": + return "Lexus" + } + b := strings.TrimSpace(brand) + return strings.ToUpper(b[:1]) + strings.ToLower(b[1:]) +} + +// vehicleDisplayName is the name the car gets by default: the nickname the owner +// already gave the vehicle in the app, else make + model, else the VIN. +func vehicleDisplayName(obj map[string]any, v providerVehicle) string { + if alias := findString(obj, "alias", "nickName", "displayName", "vehicleName"); alias != "" { + return alias + } + if name := strings.TrimSpace(v.Make + " " + v.Model); name != "" { + return name + } + return v.ID +} + +// vehicleObjects digs the list of vehicle objects out of a plugin payload, +// accepting the shapes these APIs use interchangeably: a bare array, an +// envelope with a "payload" array, an envelope wrapping a single object, or an +// array nested somewhere else entirely. +func vehicleObjects(raw json.RawMessage) []map[string]any { + var tree any + if json.Unmarshal(raw, &tree) != nil { + return nil + } + + if node, ok := findNode(tree, "payload", "vehicles", "items", "data"); ok { + if objs := asObjectSlice(node); objs != nil { + return objs + } + } + if objs := asObjectSlice(tree); objs != nil { + return objs + } + return firstObjectSlice(tree) +} + +// asObjectSlice reads a node as a list of objects, treating a lone object as a +// one-element list. +func asObjectSlice(node any) []map[string]any { + switch v := node.(type) { + case []any: + out := make([]map[string]any, 0, len(v)) + for _, e := range v { + if m, ok := e.(map[string]any); ok { + out = append(out, m) + } + } + if len(out) > 0 { + return out + } + case map[string]any: + return []map[string]any{v} + } + return nil +} + +// firstObjectSlice finds the outermost array of objects anywhere in a tree — the +// last resort when the envelope key is not one we know. +func firstObjectSlice(root any) []map[string]any { + queue, visited := []any{root}, 0 + for len(queue) > 0 && visited < maxWalkNodes { + node := queue[0] + queue = queue[1:] + visited++ + + if arr, ok := node.([]any); ok { + if objs := asObjectSlice(arr); objs != nil { + return objs + } + } + switch v := node.(type) { + case map[string]any: + for _, k := range sortedKeys(v) { + queue = append(queue, v[k]) + } + case []any: + queue = append(queue, v...) + } + } + return nil +} + +// normalizeProviderFuelType maps whatever a provider calls a powertrain onto the +// app's fuel_type enum. Substring matching on purpose: the same drivetrain +// arrives as "HV", "hybrid", "Hybrid Electric" and "PETROL_HYBRID" depending on +// the endpoint. An unrecognized value yields "" so the field is simply left +// unset rather than written wrong. +func normalizeProviderFuelType(v string) string { + s := strings.ToLower(strings.TrimSpace(v)) + if s == "" { + return "" + } + switch { + case strings.Contains(s, "hydrogen"), strings.Contains(s, "fcev"), strings.Contains(s, "fuelcell"): + return "hydrogen" + // Plug-in and mild hybrids both run on petrol too, so hybrid is checked + // before the bare fuels — "petrol hybrid" must not land on "petrol". + case strings.Contains(s, "hybrid"), s == "hv", s == "phev", s == "mhev", strings.Contains(s, "phv"): + return "hybrid" + case strings.Contains(s, "diesel"): + return "diesel" + case strings.Contains(s, "electric"), s == "ev", s == "bev": + return "electric" + case strings.Contains(s, "petrol"), strings.Contains(s, "gasoline"), s == "gas": + return "petrol" + } + return "" +} + +// isoDateOnly reduces a provider timestamp to the YYYY-MM-DD the car's date +// fields store. Anything that is not already an ISO date is dropped, rather than +// guessed at: "01/06/2015" is June or January depending on who wrote it. +func isoDateOnly(v string) string { + s := strings.TrimSpace(v) + if len(s) < 10 || s[4] != '-' || s[7] != '-' { + return "" + } + head := s[:10] + for i, r := range head { + if i == 4 || i == 7 { + continue + } + if r < '0' || r > '9' { + return "" + } + } + return head +} diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 53292e0..0caafc5 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -34,6 +34,11 @@ var collectionsSchema = map[string][]fieldDef{ }, false), fText("build_date", false), // ISO YYYY-MM-DD (date-only) fText("first_registration_date", false), // ISO YYYY-MM-DD + // Link to the manufacturer service this car came from: the plugin name plus + // that plugin's own id for the vehicle (the VIN, for Toyota). See + // internal/api/vehicleproviders.go. Blank for a hand-entered car. + fText("provider", false), + fText("provider_vehicle_id", false), // Owner of this car. Non-cascading: deleting a user must not wipe their cars. fRelation("owner", "users", false, false), }, diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index e9c8eb2..47f8407 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -58,6 +58,14 @@ type Car struct { BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only) FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only) + // Provider links this car to the manufacturer service it came from — the name + // of the plugin ("toyota"), plus that plugin's own id for the vehicle + // (ProviderVehicleID; the VIN, for Toyota). Both are blank for a hand-entered + // car. They are set by the import/link endpoints only, never by an ordinary + // car edit, so saving the form cannot silently break the link. + Provider string `json:"provider,omitempty"` + ProviderVehicleID string `json:"providerVehicleId,omitempty"` + // Owner is the user id that owns this car. Access is the requesting user's // permission on it — "owner", "write", or "read" — computed by the API at // read time and never persisted (omitempty; not part of the write payload). diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md index a220906..c06cb47 100644 --- a/API Server/internal/plugins/README.md +++ b/API Server/internal/plugins/README.md @@ -36,9 +36,11 @@ type Plugin interface { plugin is enabled or its config changes. Prepare clients/tokens here. - **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}` where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`. -- **`Invoke`** — run a named capability. **Part of the contract for the future; - no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is - ready. +- **`Invoke`** — run a named capability. There is no *generic* invoke endpoint yet, + but this is live: the integration routes and the vehicle-provider layer call it + through `Manager.InvokeWith` / `InvokeBatchWith`, so implement it properly. It + must be safe for concurrent use — the live instance is shared across requests, + and `InvokeBatchWith` runs a batch of actions in parallel on one instance. - **`Shutdown`** — release resources. ### Descriptor & config fields @@ -139,7 +141,8 @@ func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { } func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { - // Implement your capabilities; return normalized JSON. (Not yet called in v1.) + // Implement your capabilities; return normalized JSON. Must be safe for + // concurrent use — one instance serves many requests. return json.RawMessage(`{"ok":true}`), nil } @@ -300,8 +303,10 @@ if h := p.HealthCheck(context.Background()); h.Status == "" { The contract is shaped for these; see [`doc.go`](doc.go): -- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized - request/response envelope and a provider→internal mapper. +- **Generic invocation API** — an endpoint to call *any* plugin's `Invoke` from a + client, with a normalized request/response envelope. The purpose-built callers + exist (`Manager.InvokeWith` / `InvokeBatchWith`, driven by the integration routes + and `internal/api/vehicleproviders.go`); what is missing is the generic route. - **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics. - **Per-tenant credentials _for arbitrary plugins_** — the two built-in connectors already have them, through the hand-written `/api/integrations/toyota` and @@ -312,5 +317,7 @@ The contract is shaped for these; see [`doc.go`](doc.go): - **Audit logging** of plugin access. (Charger *control* commands are already audited to the `control_audit` collection; this is the wider plugin case.) -Until the invocation API lands, `Invoke` is dormant — plugins are discoverable, -configurable, and health-checked, but not yet callable over HTTP. +Until the generic invocation API lands, `Invoke` is reachable only through the +purpose-built routes: the two integrations' own endpoints, and the vehicle-provider +layer that builds a car from a manufacturer account and feeds the car's provider +tab (see `internal/api/vehicleproviders.go`). diff --git a/API Server/internal/plugins/manager.go b/API Server/internal/plugins/manager.go index 2698512..0e1397c 100644 --- a/API Server/internal/plugins/manager.go +++ b/API Server/internal/plugins/manager.go @@ -357,6 +357,63 @@ func (m *Manager) InvokeWith(ctx context.Context, name string, cfg map[string]st return p.Invoke(ctx, action, payload) } +// BatchCall is one capability invocation inside an InvokeBatchWith request. +type BatchCall struct { + ID string // caller-chosen id, echoed back on the result + Action string // capability id + Params json.RawMessage // action params; may be nil +} + +// BatchResult is the outcome of one BatchCall. Exactly one of Result/Err is set. +type BatchResult struct { + ID string + Result json.RawMessage + Err error +} + +// batchConcurrency caps how many calls of one batch are in flight at once, so a +// snapshot of a whole vehicle doesn't arrive at the upstream as a burst. +const batchConcurrency = 4 + +// InvokeBatchWith runs several capabilities against one caller-resolved config, +// sharing a single transient instance. A connector that authenticates lazily +// (Toyota's OAuth login on first request) would otherwise repeat that login for +// every action, because InvokeWith builds and tears down an instance per call; +// sharing the instance logs in once for the whole batch. +// +// Calls run concurrently, so a plugin's Invoke must be safe for concurrent use — +// which the contract already implies, since the live instance is shared by every +// HTTP request. Results come back in request order, each carrying its own error; +// a non-nil error return means the batch never started (unknown plugin). +func (m *Manager) InvokeBatchWith(ctx context.Context, name string, cfg map[string]string, calls []BatchCall) ([]BatchResult, error) { + m.mu.Lock() + rec := m.records[name] + p := construct(name, m.factories[name], rec) + m.mu.Unlock() + + if p == nil { + return nil, errUnknown + } + _ = p.Init(ctx, cfg) + defer func() { _ = p.Shutdown(context.Background()) }() + + out := make([]BatchResult, len(calls)) + sem := make(chan struct{}, batchConcurrency) + var wg sync.WaitGroup + for i, c := range calls { + wg.Add(1) + go func(i int, c BatchCall) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + res, err := p.Invoke(ctx, c.Action, c.Params) + out[i] = BatchResult{ID: c.ID, Result: res, Err: err} + }(i, c) + } + wg.Wait() + return out, nil +} + // RawConfig returns a copy of a plugin's stored (global) config and its enabled // flag. ok is false for an unknown plugin. This is the top layer (L1) of the // per-user cascade: the config a superadmin set in the panel, which lower layers diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 9a56222..ac15554 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -266,6 +266,12 @@ const DESIRED = { ]), F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year) F.text("first_registration_date"), // ISO YYYY-MM-DD + // Link to the manufacturer service this car came from (see + // internal/api/vehicleproviders.go): the plugin name, plus that plugin's own + // id for the vehicle (the VIN, for Toyota). Set when a car is imported from + // or linked to a connected account; blank for a hand-entered car. + F.text("provider"), + F.text("provider_vehicle_id"), // Owner of this car. Non-cascading on purpose: deleting a user must not // wipe their cars (account deletion in me.go intentionally leaves cars). // required:false at the DB level — the API always sets owner on create and diff --git a/README.md b/README.md index 32ce347..86c9aa0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ export/import). - **Integrations** — per-user connectors under a superadmin → org-admin → user cascade. Built-in today: **Toyota Connected** (read-only vehicle data) and the **Anker Solix** V1 EV charger. +- **Cars from the manufacturer's own service** — import a car straight off a + connected account (MyToyota today), choosing what to pull in, and read everything + that service knows about it from a dedicated first tab on the car. Generic over + providers: the next manufacturer is one adapter in the API Server. - **EV charging control** — for Anker Solix chargers the API Server runs an **OCPP 1.6J Central System**; in own/proxy mode the charger dials back in and the owner can start/stop and set limits from the Charging screen. diff --git a/Web App/README.md b/Web App/README.md index c7f3b5e..3e9cbce 100644 --- a/Web App/README.md +++ b/Web App/README.md @@ -84,10 +84,20 @@ Config (`server/.env`, copy from `.env.example`): - **Dashboard** — one card per car: last service, odometer, next-due date/km, and a status badge (OK / due soon ≤30d / overdue) from the Excel formulas. Add a - car; shared cars are labelled and gated by your access level. + car by hand, or **import from service** — pick a vehicle off a connected + manufacturer account and have its details filled in (the button appears only + once an account is connected). Shared cars are labelled and gated by your access + level. - **Car detail** — all car spec fields (engine / transmission / differential oil, brake fluid, coolant, VIN, fuel type, …) plus tabbed histories, each with an optional file attachment and add/edit/delete gated by your access level: + - **The connected service** (e.g. **MyToyota**) — the first tab, present for a + car linked to a manufacturer account: live readings (odometer, fuel, battery, + range, position), the vehicle record, and every section the plugin can fetch + with its raw response. Offers the provider's odometer when it is ahead of the + stored one. On an unlinked car the tab instead offers to connect it to a + vehicle on your account. Read under *your* account, so a car shared from + someone else shows data only if that vehicle is on your account too. - **Service history** — date, km, computed next date/km, and changed-parts flags. - **Technical checks** — roadworthiness inspections; result, cost, station and the certificate's valid-until, which drives the next-due date. diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 3a67c6f..6d987f6 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -231,6 +231,35 @@ export const api = { saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }), testToyota: () => request("/integrations/toyota/health", { method: "POST" }), + // Vehicle providers — manufacturer services a car can be created from, and the + // data feed behind a car's provider tab. Every call runs server-side under the + // caller's *own* connected account (the same cascade the Settings integrations + // use), so a car shared from someone else only shows provider data when that + // vehicle is on this user's account too. + // + // listVehicleProviders reports each provider with a `connected` flag and, when + // it isn't, a `detail` sentence explaining what to do about it — the list is + // never an error, so the UI can offer "connect in Settings" instead. + listVehicleProviders: () => request("/vehicle-providers").then((r) => r.providers), + listProviderVehicles: (provider) => + request(`/vehicle-providers/${encodeURIComponent(provider)}/vehicles`), + // include selects what to pull; omit it entirely to fetch everything available. + importProviderVehicle: (provider, body) => + request(`/vehicle-providers/${encodeURIComponent(provider)}/import`, { + method: "POST", + body: JSON.stringify(body), + }), + + // One car's live provider snapshot: the vehicle record, headline readings, and + // every section the plugin can fetch (each with its flattened fields and the + // raw payload). linkCarProvider attaches an existing car to a vehicle — pass an + // empty provider to detach; syncCarProvider re-applies provider data to the car. + getCarProvider: (carId) => request(`/cars/${carId}/provider`), + linkCarProvider: (carId, body) => + request(`/cars/${carId}/provider`, { method: "POST", body: JSON.stringify(body) }), + syncCarProvider: (carId, body = {}) => + request(`/cars/${carId}/provider/sync`, { method: "POST", body: JSON.stringify(body) }), + // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix // returns the resolved view (effective/own/locked per field, secrets and // inherited emails masked); saveAnkerSolix writes the caller's editable layer; diff --git a/Web App/web/src/components/CarImportModal.vue b/Web App/web/src/components/CarImportModal.vue new file mode 100644 index 0000000..b729e45 --- /dev/null +++ b/Web App/web/src/components/CarImportModal.vue @@ -0,0 +1,217 @@ + + + diff --git a/Web App/web/src/components/ProviderPanel.vue b/Web App/web/src/components/ProviderPanel.vue new file mode 100644 index 0000000..8a2bae3 --- /dev/null +++ b/Web App/web/src/components/ProviderPanel.vue @@ -0,0 +1,342 @@ + + + diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 7e0dcb1..5628a95 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -11,6 +11,7 @@ "saved": "Gemt ✓", "loading": "Indlæser…", "edit": "Rediger", + "open": "Åbn", "remove": "Fjern", "delete": "Slet", "done": "Færdig", @@ -87,6 +88,7 @@ "title": "Dine biler", "subtitle": "Serviceoverblik og servicehistorik.", "addCar": "Tilføj bil", + "importCar": "Importér fra tjeneste", "empty": "Ingen biler endnu. Klik på {action} for at komme i gang.", "shared": "Delt", "sharedReadOnly": "Delt · skrivebeskyttet", @@ -242,6 +244,7 @@ "sharedReadOnly": "Delt · skrivebeskyttet", "tabs": { + "connected": "Tilsluttet tjeneste", "info": "Oplysninger", "services": "Servicehistorik", "technical": "Synshistorik", @@ -252,6 +255,50 @@ "reminders": "Påmindelser" }, + "provider": { + "subtitle": "Live-data fra din {label}-konto.", + "refresh": "Opdater", + "refreshing": "Opdaterer…", + "updated": "Opdateret {time}", + "vehicle": "Køretøj", + "readings": "Aktuelle målinger", + "noData": "{label} returnerede ingen data for denne bil.", + "raw": "Rå svar", + "allFields": "Alle oplyste felter", + "truncated": "Kun de første {n} felter er vist — resten findes i det rå svar nedenfor.", + "sectionEmpty": "Intet oplyst.", + "own": "Kun din egen konto bruges, så loginoplysninger deles aldrig sammen med en bil.", + "odometerSuggest": "{label} oplyser {km}, altså mere end bilens gemte kilometerstand.", + "updateOdometer": "Opdater kilometerstand", + "updatingOdometer": "Opdaterer…", + "unlink": "Afbryd", + "unlinkConfirm": "Afbryd denne bil fra {label}? Intet gemt slettes.", + "connectTitle": "Forbind denne bil til en tjeneste", + "connectHint": "Vælg det køretøj på din konto, der svarer til denne bil. Dens data vises så her.", + "connectSubmit": "Forbind køretøj", + "connecting": "Forbinder…", + "noProviders": "Ingen producentkonto er tilsluttet. Tilføj en under Indstillinger › Integrationer.", + "settingsLink": "Åbn Indstillinger", + "sections": { + "telemetry": "Kilometerstand og rækkevidde", + "electric": "Batteri og opladning", + "status": "Døre, ruder og lys", + "health": "Køretøjets tilstand", + "location": "Sidst kendte position", + "serviceHistory": "Servicehistorik hos forhandler", + "notifications": "Notifikationer" + }, + "metrics": { + "odometer": "Kilometerstand", + "fuelLevel": "Brændstofniveau", + "fuelRange": "Rækkevidde", + "batteryLevel": "Batteri", + "evRange": "Elektrisk rækkevidde", + "chargingStatus": "Opladning", + "location": "Position" + } + }, + "info": { "oilSpec": "Motorolie-specifikation", "transmissionOil": "Gearolie", @@ -454,6 +501,29 @@ "submit": "Tilføj bil" }, + "import": { + "title": "Importér en bil", + "subtitle": "Opret en bil ud fra et køretøj på din producentkonto. Alt, hvad der kan læses, udfyldes for dig.", + "service": "Tjeneste", + "loadingVehicles": "Indlæser dine køretøjer…", + "noVehicles": "Ingen køretøjer på denne konto.", + "notConnected": "Ikke tilsluttet", + "noProviders": "Ingen producentkonto er tilsluttet. Tilføj en under Indstillinger › Integrationer.", + "alreadyInGarage": "Allerede i din garage", + "selectVehicle": "Køretøj", + "dataTitle": "Hvad skal importeres", + "dataHint": "Fjern fluebenet ved det, du helst selv vil udfylde.", + "includeIdentity": "Mærke, model, årgang, nummerplade og VIN", + "includeFuelType": "Brændstoftype", + "includeDates": "Produktions- og første registreringsdato", + "includeOdometer": "Aktuel kilometerstand", + "name": "Bilens navn", + "submit": "Importér bil", + "importing": "Importerer…", + "warningOdometer": "Tjenesten oplyste ingen kilometerstand — indtast den selv på bilen.", + "moreData": "Resten af det, tjenesten oplyser, er fortsat tilgængeligt på bilens {label}-fane." + }, + "service": { "addTitle": "Tilføj servicepost", "editTitle": "Rediger servicepost", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 63fb347..544329e 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -11,6 +11,7 @@ "saved": "Saved ✓", "loading": "Loading…", "edit": "Edit", + "open": "Open", "remove": "Remove", "delete": "Delete", "done": "Done", @@ -105,6 +106,7 @@ "title": "Your cars", "subtitle": "Maintenance overview and service history.", "addCar": "Add car", + "importCar": "Import from service", "empty": "No cars yet. Click {action} to get started.", "shared": "Shared", "sharedReadOnly": "Shared · read-only", @@ -317,6 +319,7 @@ "sharedReadOnly": "Shared · read-only", "tabs": { + "connected": "Connected service", "info": "Information", "services": "Service history", "technical": "Technical check history", @@ -327,6 +330,50 @@ "reminders": "Reminders" }, + "provider": { + "subtitle": "Live data from your {label} account.", + "refresh": "Refresh", + "refreshing": "Refreshing…", + "updated": "Updated {time}", + "vehicle": "Vehicle", + "readings": "Current readings", + "noData": "{label} returned no data for this car.", + "raw": "Raw response", + "allFields": "All reported fields", + "truncated": "Only the first {n} fields are listed — the raw response below has the rest.", + "sectionEmpty": "Nothing reported.", + "own": "Only your own account is used, so credentials are never shared with a car.", + "odometerSuggest": "{label} reports {km}, ahead of this car's stored reading.", + "updateOdometer": "Update odometer", + "updatingOdometer": "Updating…", + "unlink": "Disconnect", + "unlinkConfirm": "Disconnect this car from {label}? Nothing already saved is deleted.", + "connectTitle": "Connect this car to a service", + "connectHint": "Pick the vehicle on your account that matches this car. Its data then appears here.", + "connectSubmit": "Connect vehicle", + "connecting": "Connecting…", + "noProviders": "No manufacturer account is connected. Add one in Settings › Integrations.", + "settingsLink": "Open Settings", + "sections": { + "telemetry": "Odometer & range", + "electric": "Battery & charging", + "status": "Doors, windows & lights", + "health": "Vehicle health", + "location": "Last known location", + "serviceHistory": "Dealer service history", + "notifications": "Notifications" + }, + "metrics": { + "odometer": "Odometer", + "fuelLevel": "Fuel level", + "fuelRange": "Range", + "batteryLevel": "Battery", + "evRange": "Electric range", + "chargingStatus": "Charging", + "location": "Position" + } + }, + "info": { "oilSpec": "Engine oil spec", "transmissionOil": "Transmission oil", @@ -529,6 +576,29 @@ "submit": "Add car" }, + "import": { + "title": "Import a car", + "subtitle": "Create a car from a vehicle on your manufacturer account. Everything it can read is filled in for you.", + "service": "Service", + "loadingVehicles": "Loading your vehicles…", + "noVehicles": "No vehicles on this account.", + "notConnected": "Not connected", + "noProviders": "No manufacturer account is connected. Add one in Settings › Integrations.", + "alreadyInGarage": "Already in your garage", + "selectVehicle": "Vehicle", + "dataTitle": "What to import", + "dataHint": "Uncheck anything you would rather fill in yourself.", + "includeIdentity": "Make, model, year, registration and VIN", + "includeFuelType": "Fuel type", + "includeDates": "Build and first-registration dates", + "includeOdometer": "Current odometer", + "name": "Car name", + "submit": "Import car", + "importing": "Importing…", + "warningOdometer": "The service did not report an odometer reading — enter it yourself on the car.", + "moreData": "The rest of what this service reports stays available on the car's {label} tab." + }, + "service": { "addTitle": "Add service record", "editTitle": "Edit service record", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 814a1fd..cc4c1be 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -11,6 +11,7 @@ "saved": "Zapisano ✓", "loading": "Ładowanie…", "edit": "Edytuj", + "open": "Otwórz", "remove": "Usuń", "delete": "Usuń", "done": "Gotowe", @@ -89,6 +90,7 @@ "title": "Twoje samochody", "subtitle": "Przegląd serwisowy i historia napraw.", "addCar": "Dodaj samochód", + "importCar": "Importuj z serwisu", "empty": "Nie masz jeszcze samochodów. Kliknij {action}, aby zacząć.", "shared": "Udostępniony", "sharedReadOnly": "Udostępniony · tylko do odczytu", @@ -246,6 +248,7 @@ "sharedReadOnly": "Udostępniony · tylko do odczytu", "tabs": { + "connected": "Połączona usługa", "info": "Informacje", "services": "Historia serwisowa", "technical": "Historia przeglądów", @@ -256,6 +259,50 @@ "reminders": "Przypomnienia" }, + "provider": { + "subtitle": "Dane na żywo z Twojego konta {label}.", + "refresh": "Odśwież", + "refreshing": "Odświeżanie…", + "updated": "Zaktualizowano {time}", + "vehicle": "Pojazd", + "readings": "Aktualne odczyty", + "noData": "{label} nie zwróciło żadnych danych dla tego samochodu.", + "raw": "Surowa odpowiedź", + "allFields": "Wszystkie zgłoszone pola", + "truncated": "Wypisano tylko pierwsze {n} pól — pozostałe znajdziesz w surowej odpowiedzi poniżej.", + "sectionEmpty": "Brak danych.", + "own": "Używane jest wyłącznie Twoje własne konto, więc dane logowania nigdy nie są udostępniane wraz z samochodem.", + "odometerSuggest": "{label} podaje {km}, czyli więcej niż zapisany przebieg tego samochodu.", + "updateOdometer": "Zaktualizuj przebieg", + "updatingOdometer": "Aktualizowanie…", + "unlink": "Odłącz", + "unlinkConfirm": "Odłączyć ten samochód od {label}? Żadne zapisane dane nie zostaną usunięte.", + "connectTitle": "Połącz ten samochód z usługą", + "connectHint": "Wybierz pojazd ze swojego konta, który odpowiada temu samochodowi. Jego dane pojawią się tutaj.", + "connectSubmit": "Połącz pojazd", + "connecting": "Łączenie…", + "noProviders": "Nie połączono żadnego konta producenta. Dodaj je w Ustawieniach › Integracje.", + "settingsLink": "Otwórz Ustawienia", + "sections": { + "telemetry": "Przebieg i zasięg", + "electric": "Akumulator i ładowanie", + "status": "Drzwi, szyby i światła", + "health": "Stan pojazdu", + "location": "Ostatnia znana lokalizacja", + "serviceHistory": "Historia serwisowa u dealera", + "notifications": "Powiadomienia" + }, + "metrics": { + "odometer": "Przebieg", + "fuelLevel": "Poziom paliwa", + "fuelRange": "Zasięg", + "batteryLevel": "Akumulator", + "evRange": "Zasięg elektryczny", + "chargingStatus": "Ładowanie", + "location": "Pozycja" + } + }, + "info": { "oilSpec": "Specyfikacja oleju silnikowego", "transmissionOil": "Olej przekładniowy", @@ -468,6 +515,29 @@ "submit": "Dodaj samochód" }, + "import": { + "title": "Importuj samochód", + "subtitle": "Utwórz samochód na podstawie pojazdu z Twojego konta u producenta. Wszystko, co da się odczytać, zostanie wypełnione automatycznie.", + "service": "Usługa", + "loadingVehicles": "Wczytywanie Twoich pojazdów…", + "noVehicles": "Brak pojazdów na tym koncie.", + "notConnected": "Nie połączono", + "noProviders": "Nie połączono żadnego konta producenta. Dodaj je w Ustawieniach › Integracje.", + "alreadyInGarage": "Już w Twoim garażu", + "selectVehicle": "Pojazd", + "dataTitle": "Co zaimportować", + "dataHint": "Odznacz to, co wolisz wpisać samodzielnie.", + "includeIdentity": "Marka, model, rok, rejestracja i VIN", + "includeFuelType": "Rodzaj paliwa", + "includeDates": "Data produkcji i pierwszej rejestracji", + "includeOdometer": "Aktualny przebieg", + "name": "Nazwa samochodu", + "submit": "Importuj samochód", + "importing": "Importowanie…", + "warningOdometer": "Usługa nie podała przebiegu — wpisz go samodzielnie w samochodzie.", + "moreData": "Pozostałe dane z tej usługi pozostają dostępne w karcie {label} samochodu." + }, + "service": { "addTitle": "Dodaj wpis serwisowy", "editTitle": "Edytuj wpis serwisowy", diff --git a/Web App/web/src/lib/format.js b/Web App/web/src/lib/format.js index 20207ae..289e3c5 100644 --- a/Web App/web/src/lib/format.js +++ b/Web App/web/src/lib/format.js @@ -29,6 +29,18 @@ export function formatDate(value) { } } +// A timestamp rather than a date: the date in the user's chosen format plus the +// clock time in their region's convention. For the places where freshness is the +// whole point — a live reading pulled from a manufacturer service means little +// without the minute it was taken. +export function formatDateTime(value) { + if (!value) return "—"; + const d = new Date(value); + if (isNaN(d)) return "—"; + const time = d.toLocaleTimeString(prefs.locale || undefined, { hour: "2-digit", minute: "2-digit" }); + return `${formatDate(value)} ${time}`; +} + // Every number we render goes through here so the grouping separator follows // the user's chosen region rather than the browser's own locale — otherwise the // odometer disagrees with the dates and costs beside it. diff --git a/Web App/web/src/views/CarDetail.vue b/Web App/web/src/views/CarDetail.vue index 7836973..b1442ee 100644 --- a/Web App/web/src/views/CarDetail.vue +++ b/Web App/web/src/views/CarDetail.vue @@ -23,6 +23,7 @@ import MaintenanceFormModal from "../components/MaintenanceFormModal.vue"; import DocumentFormModal from "../components/DocumentFormModal.vue"; import ReminderFormModal from "../components/ReminderFormModal.vue"; import ShareModal from "../components/ShareModal.vue"; +import ProviderPanel from "../components/ProviderPanel.vue"; const props = defineProps({ id: { type: String, required: true } }); const router = useRouter(); @@ -76,6 +77,23 @@ const isReadOnly = computed(() => car.value?.access === "read"); const activeTab = ref("info"); +// Connected-service tab. It leads the bar — ahead of Information — because for a +// car imported from a manufacturer account that is the live view of the car, +// while everything to its right is the record the user keeps by hand. +// +// It shows for a linked car (labelled with the service, "MyToyota") and also for +// an unlinked one as long as the user has some account connected, where it offers +// to link this car to a vehicle on it. A user with nothing connected never sees +// the tab at all. +const providers = ref([]); +const providerLabel = computed(() => { + const linked = providers.value.find((p) => p.id === car.value?.provider); + return linked?.label || car.value?.provider || t("car.tabs.connected"); +}); +const showProviderTab = computed( + () => !!car.value?.provider || providers.value.some((p) => p.connected) +); + // Count of reminders wanting attention, surfaced on the tab so it is visible // without opening it — the whole point of a reminder. const dueReminders = computed( @@ -85,6 +103,7 @@ const dueReminders = computed( // Computed, not a plain array: t() reads the reactive locale, so the tab labels // have to re-evaluate when the language changes. const TABS = computed(() => [ + ...(showProviderTab.value ? [{ key: "provider", label: providerLabel.value }] : []), { key: "info", label: t("car.tabs.info") }, { key: "services", label: t("car.tabs.services") }, { key: "technical", label: t("car.tabs.technical") }, @@ -125,6 +144,14 @@ async function load() { } finally { loading.value = false; } + + // Which manufacturer services the user has connected — it decides whether the + // connected-service tab appears. Fired separately and failure-tolerant: a + // plugin being down must not take the car page with it. + api + .listVehicleProviders() + .then((list) => (providers.value = list)) + .catch(() => {}); } function openAddService() { @@ -364,6 +391,13 @@ async function onCarSaved(updated) { car.value = updated; } +// The provider panel writes to the car too — it links/unlinks the connected +// service and can take the odometer from it, which moves km-triggered reminders. +async function onCarUpdated(updated) { + car.value = updated; + reminders.value = await api.listCarReminders(props.id); +} + function openDeleteCar() { deleteConfirmText.value = ""; showDeleteCar.value = true; @@ -470,8 +504,18 @@ onMounted(load); + + + -
+
{{ t("car.info.oilSpec") }}
{{ car.oilSpec || t("common.empty") }}
diff --git a/Web App/web/src/views/Dashboard.vue b/Web App/web/src/views/Dashboard.vue index e08cfeb..caafa44 100644 --- a/Web App/web/src/views/Dashboard.vue +++ b/Web App/web/src/views/Dashboard.vue @@ -5,12 +5,18 @@ import { api } from "../api"; import { formatDate, formatKm, serviceStatus } from "../lib/format.js"; import { t, tSplit } from "../i18n"; import CarFormModal from "../components/CarFormModal.vue"; +import CarImportModal from "../components/CarImportModal.vue"; const router = useRouter(); const cars = ref([]); const loading = ref(true); const error = ref(""); const showAdd = ref(false); +const showImport = ref(false); + +// Importing only makes sense once a manufacturer account is connected, so the +// button appears only then rather than leading to a dead end. +const canImport = ref(false); async function load() { loading.value = true; @@ -37,6 +43,15 @@ function onSaved(car) { router.push({ name: "car", params: { id: car.id } }); } +// An imported car lands on its own page like a hand-added one. A warning means a +// field the service couldn't supply (an odometer it doesn't report); the car is +// created either way, so say what to fill in rather than block the import. +function onImported(car, warnings) { + showImport.value = false; + if (warnings?.includes("odometer")) alert(t("forms.import.warningOdometer")); + router.push({ name: "car", params: { id: car.id } }); +} + // Service-life progress: how far the car is through its km service interval. // Returns a { pct, tone } or null when there isn't enough data to compute it. const TONE_COLOR = { @@ -55,7 +70,14 @@ function serviceLife(car) { return { pct, tone: TONE_COLOR[serviceStatus(car.latest, car).key] || TONE_COLOR.unknown }; } -onMounted(load); +onMounted(() => { + load(); + // Failure-tolerant: a plugin problem must not stop the garage from rendering. + api + .listVehicleProviders() + .then((list) => (canImport.value = list.some((p) => p.connected))) + .catch(() => {}); +});