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(() => {}); +});