Cars: create a car from a manufacturer service, with a per-car data tab

A car can now be imported straight from the account its owner already has
with the manufacturer, and every reading that service exposes shows up on
the car's own tab. MyToyota is the first provider.

API Server — internal/api/vehicleproviders.go adds a generic layer over a
plugin that can enumerate vehicles and read data about them. Adding the
next manufacturer is one vehicleSource adapter plus a line in
vehicleSources(): no new endpoints, no Web App changes.

  GET  /api/vehicle-providers                     providers + connect state
  GET  /api/vehicle-providers/{p}/vehicles        the caller's vehicles
  POST /api/vehicle-providers/{p}/import          create a car from one
  GET  /api/cars/{id}/provider                    live snapshot for the tab
  POST /api/cars/{id}/provider                    link / unlink a car
  POST /api/cars/{id}/provider/sync               re-apply provider data

Two properties shape it. Credentials are always the caller's own, resolved
through the same global -> org -> user cascade as the integration settings,
so a shared car shows provider data only when that vehicle is on the
viewer's account — the owner's credentials are never borrowed. And upstream
shapes are not modelled: these are unofficial APIs, so the layer searches
payloads by key name for the readings worth promoting (odometer, fuel,
battery, range) and flattens the rest to dotted key/value pairs alongside
the raw JSON. A renamed field costs one blank value, not a broken page.

The Toyota gate and its wording now live in toyotaSource, so the older
/api/integrations/toyota/vehicles endpoint and the new ones cannot drift.

Manager.InvokeBatchWith shares one transient plugin instance across a batch
of actions. The tab pulls seven capabilities, and InvokeWith builds a fresh
instance per call — which for a connector that authenticates lazily means a
fresh OAuth login per call. Batching logs in once.

cars gains provider + provider_vehicle_id (schema.go and
setup-pocketbase.mjs both). carPayload deliberately omits them, so an
ordinary car edit can neither reassign the car nor break its link;
carProviderPayload writes the link on its own.

Web App — Dashboard grows an "import from service" button beside "add car",
shown only once an account is connected, opening CarImportModal: pick the
vehicle, choose what to pull (identity / fuel type / dates / odometer, all
on by default), import. ProviderPanel becomes the car's first tab, ahead of
Information, labelled with the service: headline readings, the vehicle
record, one card per capability with its raw response, and an offer to take
the provider's odometer when it is ahead of the stored one. On an unlinked
car the tab instead offers to link it, VIN-matched. Info stays the default
selection — landing on the provider tab would fire a login on every car
page view. Full en/pl/da translations.

Tests cover the payload walking, Toyota normalization, import-selection
defaults, and — through the real handler chain against a stand-in
PocketBase — that every route is registered and that a closed gate is soft
on a listing (200 + a reason the UI can show) but hard on a write (4xx, so
a caller cannot read the reply as a created car).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-17 14:13:11 +02:00
co-authored by Claude Opus 5
parent 47a9aef466
commit 358ee68f94
23 changed files with 2983 additions and 43 deletions
+36 -1
View File
@@ -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 (`/`)
+12 -26
View File
@@ -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()})
+18 -1
View File
@@ -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 {
+17
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
}
@@ -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
}
+5
View File
@@ -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),
},
+8
View File
@@ -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).
+15 -8
View File
@@ -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`).
+57
View File
@@ -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
+6
View File
@@ -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
+4
View File
@@ -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.
+11 -1
View File
@@ -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.
+29
View File
@@ -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;
@@ -0,0 +1,217 @@
<script setup>
// Create a car from a vehicle on a connected manufacturer account (MyToyota
// today; the endpoints are generic, so the next provider needs no changes here).
//
// The three steps are one screen on purpose: with a single connected service and
// one car on it, importing is two clicks — pick the vehicle, press Import — and
// the data checkboxes are there for the person who would rather type the plate
// themselves. Everything is checked by default, because "fetch what you can" is
// what someone importing a car is asking for.
import { ref, computed, onMounted, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const emit = defineEmits(["saved", "close", "open-car"]);
const providers = ref([]);
const provider = ref("");
const vehicles = ref([]);
const selectedId = ref("");
const name = ref("");
const loading = ref(true);
const loadingVehicles = ref(false);
const importing = ref(false);
const error = ref("");
// Why the provider can't be used right now (not connected, org switch off, …).
// The server phrases this; the UI just shows it.
const detail = ref("");
// Every group on by default — see the note above.
const include = ref({ identity: true, fuelType: true, dates: true, odometer: true });
const connected = computed(() => providers.value.filter((p) => p.connected));
const current = computed(() => providers.value.find((p) => p.id === provider.value) || null);
const selected = computed(() => vehicles.value.find((v) => v.id === selectedId.value) || null);
const canImport = computed(() => !!selected.value && !selected.value.linkedCarId && !importing.value);
async function loadProviders() {
loading.value = true;
error.value = "";
try {
providers.value = await api.listVehicleProviders();
provider.value = connected.value[0]?.id || providers.value[0]?.id || "";
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function loadVehicles() {
vehicles.value = [];
selectedId.value = "";
detail.value = "";
if (!provider.value) return;
if (!current.value?.connected) {
detail.value = current.value?.detail || "";
return;
}
loadingVehicles.value = true;
error.value = "";
try {
const res = await api.listProviderVehicles(provider.value);
vehicles.value = res.vehicles || [];
detail.value = res.unavailable ? res.detail || "" : "";
// Preselect the first vehicle that isn't in the garage already — with one
// car on the account that is the whole selection step.
selectedId.value = vehicles.value.find((v) => !v.linkedCarId)?.id || "";
} catch (e) {
error.value = e.message;
} finally {
loadingVehicles.value = false;
}
}
// The name field tracks the selected vehicle until the user types their own.
const nameEdited = ref(false);
watch(selected, (v) => {
if (!nameEdited.value) name.value = v?.name || "";
});
watch(provider, loadVehicles);
function subtitle(v) {
return [v.make, v.model, v.year || ""].filter(Boolean).join(" ");
}
async function submit() {
if (!canImport.value) return;
importing.value = true;
error.value = "";
try {
const res = await api.importProviderVehicle(provider.value, {
vehicleId: selected.value.id,
name: name.value.trim(),
include: include.value,
});
emit("saved", res.car, res.warnings || []);
} catch (e) {
error.value = e.message;
} finally {
importing.value = false;
}
}
onMounted(async () => {
await loadProviders();
await loadVehicles();
});
</script>
<template>
<Modal :title="t('forms.import.title')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-sm text-muted">{{ t("common.loading") }}</p>
<template v-else-if="providers.length === 0">
<p class="text-sm text-muted">{{ t("forms.import.noProviders") }}</p>
<div class="mt-4 flex justify-end">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
</div>
</template>
<form v-else class="space-y-4" @submit.prevent="submit">
<p class="text-sm text-muted">{{ t("forms.import.subtitle") }}</p>
<!-- Service picker. Hidden while there is only one to pick. -->
<div v-if="providers.length > 1">
<label class="dh-label">{{ t("forms.import.service") }}</label>
<select v-model="provider" class="dh-input">
<option v-for="p in providers" :key="p.id" :value="p.id" :disabled="!p.connected">
{{ p.label }}{{ p.connected ? "" : " " + t("forms.import.notConnected") }}
</option>
</select>
</div>
<p v-if="detail" class="rounded-control bg-warning-soft px-3 py-2 text-sm text-warning">{{ detail }}</p>
<!-- Vehicles on the account -->
<div>
<label class="dh-label">{{ t("forms.import.selectVehicle") }}</label>
<p v-if="loadingVehicles" class="text-sm text-muted">{{ t("forms.import.loadingVehicles") }}</p>
<p v-else-if="!detail && vehicles.length === 0" class="text-sm text-muted">{{ t("forms.import.noVehicles") }}</p>
<ul v-else-if="vehicles.length" class="space-y-2">
<li v-for="v in vehicles" :key="v.id">
<label
class="flex cursor-pointer items-start gap-3 rounded-control border p-3 transition-colors"
:class="selectedId === v.id ? 'border-accent bg-sunken' : 'border-subtle hover:bg-sunken'">
<input
v-model="selectedId"
type="radio"
:value="v.id"
:disabled="!!v.linkedCarId"
class="mt-1"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-strong">{{ v.name }}</span>
<span class="block truncate text-xs text-muted">{{ subtitle(v) }}</span>
<span v-if="v.registration || v.vin" class="data mt-0.5 block truncate text-xs text-muted">
{{ [v.registration, v.vin].filter(Boolean).join(" · ") }}
</span>
<span v-if="v.linkedCarId" class="mt-1 inline-flex items-center gap-2">
<span class="dh-badge dh-badge-neutral">{{ t("forms.import.alreadyInGarage") }}</span>
<button
type="button"
class="text-xs font-medium text-brandtext hover:underline"
@click.prevent="emit('open-car', v.linkedCarId)">
{{ t("common.open") }}
</button>
</span>
</span>
</label>
</li>
</ul>
</div>
<template v-if="selected">
<div>
<label class="dh-label">{{ t("forms.import.name") }}</label>
<input v-model="name" required class="dh-input" @input="nameEdited = true" />
</div>
<fieldset class="rounded-control border border-subtle p-3">
<legend class="eyebrow px-1">{{ t("forms.import.dataTitle") }}</legend>
<p class="mb-2 text-xs text-muted">{{ t("forms.import.dataHint") }}</p>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input v-model="include.identity" type="checkbox" />
<span>{{ t("forms.import.includeIdentity") }}</span>
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input v-model="include.fuelType" type="checkbox" />
<span>{{ t("forms.import.includeFuelType") }}</span>
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input v-model="include.dates" type="checkbox" />
<span>{{ t("forms.import.includeDates") }}</span>
</label>
<label class="flex items-center gap-2 py-1 text-sm text-body">
<input v-model="include.odometer" type="checkbox" />
<span>{{ t("forms.import.includeOdometer") }}</span>
</label>
<p class="mt-2 text-xs text-muted">
{{ t("forms.import.moreData", { label: current?.label || "" }) }}
</p>
</fieldset>
</template>
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="!canImport" class="dh-btn dh-btn-primary">
{{ importing ? t("forms.import.importing") : t("forms.import.submit") }}
</button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,342 @@
<script setup>
// The connected-service tab on a car: everything the manufacturer's own app
// knows about it, fetched live.
//
// Two things shape what you see below. The data is read under *this* user's
// account — so a car shared from someone else shows nothing here unless the
// vehicle is on this user's account too, and the panel says so rather than
// failing. And the sections are whatever the plugin exposes, rendered from the
// server's flattened key/value pairs plus the raw payload: nothing here knows a
// single upstream field name, so a provider adding a field surfaces it without a
// change to this file.
import { ref, computed, watch, onMounted } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import { formatDateTime, formatKm } from "../lib/format.js";
const props = defineProps({
car: { type: Object, required: true },
canWrite: { type: Boolean, default: false },
// The provider's display name, already resolved by the parent for the tab
// label. Passed in so the heading reads "MyToyota" from the first frame rather
// than flashing the raw plugin name while the snapshot loads.
providerLabel: { type: String, default: "" },
});
const emit = defineEmits(["car-updated"]);
const snap = ref(null);
const loading = ref(false);
const error = ref("");
const syncing = ref(false);
// Connect flow, for a car that has no link yet (a car added by hand, or one from
// before this feature existed).
const providers = ref([]);
const linkProvider = ref("");
const linkVehicles = ref([]);
const linkVehicleId = ref("");
const linkLoading = ref(false);
const linking = ref(false);
const linked = computed(() => !!props.car.provider);
const label = computed(
() => snap.value?.label || props.providerLabel || currentProvider.value?.label || props.car.provider || ""
);
const currentProvider = computed(() => providers.value.find((p) => p.id === linkProvider.value) || null);
const connectable = computed(() => providers.value.filter((p) => p.connected));
// Sections that actually reported something come first; the empty and failed ones
// still render, below, so it is clear they were asked and what came back.
const sections = computed(() => snap.value?.sections || []);
async function load() {
if (!linked.value) return;
loading.value = true;
error.value = "";
try {
snap.value = await api.getCarProvider(props.car.id);
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function loadProviders() {
try {
providers.value = await api.listVehicleProviders();
linkProvider.value = connectable.value[0]?.id || "";
} catch (e) {
error.value = e.message;
}
}
async function loadLinkVehicles() {
linkVehicles.value = [];
linkVehicleId.value = "";
if (!linkProvider.value) return;
linkLoading.value = true;
try {
const res = await api.listProviderVehicles(linkProvider.value);
linkVehicles.value = res.vehicles || [];
// Prefer the vehicle whose VIN matches the car — usually the only guess needed.
const vin = (props.car.vin || "").trim().toUpperCase();
const match = vin && linkVehicles.value.find((v) => (v.vin || "").toUpperCase() === vin);
linkVehicleId.value = match?.id || linkVehicles.value[0]?.id || "";
} catch (e) {
error.value = e.message;
} finally {
linkLoading.value = false;
}
}
watch(linkProvider, loadLinkVehicles);
async function connect() {
if (!linkVehicleId.value) return;
linking.value = true;
error.value = "";
try {
const car = await api.linkCarProvider(props.car.id, {
provider: linkProvider.value,
vehicleId: linkVehicleId.value,
});
emit("car-updated", car);
await load();
} catch (e) {
error.value = e.message;
} finally {
linking.value = false;
}
}
async function disconnect() {
if (!confirm(t("car.provider.unlinkConfirm", { label: label.value }))) return;
error.value = "";
try {
const car = await api.linkCarProvider(props.car.id, { provider: "", vehicleId: "" });
snap.value = null;
emit("car-updated", car);
await loadProviders();
await loadLinkVehicles();
} catch (e) {
error.value = e.message;
}
}
// Only the odometer is written back: it is the one provider reading the rest of
// the app computes from (service intervals, km-triggered reminders), and it is
// the one the user would otherwise retype after every drive.
async function applyOdometer() {
syncing.value = true;
error.value = "";
try {
const res = await api.syncCarProvider(props.car.id, {
include: { identity: false, fuelType: false, dates: false, odometer: true },
});
emit("car-updated", res.car);
await load();
} catch (e) {
error.value = e.message;
} finally {
syncing.value = false;
}
}
function metricLabel(key) {
return t(`car.provider.metrics.${key}`);
}
function sectionLabel(id) {
return t(`car.provider.sections.${id}`);
}
function prettyJSON(raw) {
try {
return JSON.stringify(raw, null, 2);
} catch {
return String(raw);
}
}
onMounted(async () => {
if (linked.value) {
await load();
} else {
await loadProviders();
await loadLinkVehicles();
}
});
</script>
<template>
<section>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
<!-- Linked: the live snapshot -->
<template v-if="linked">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ label }}</h2>
<p class="text-sm text-muted">{{ t("car.provider.subtitle", { label }) }}</p>
</div>
<div class="flex items-center gap-2">
<button class="dh-btn dh-btn-ghost !px-3 !py-1.5" :disabled="loading" @click="load">
{{ loading ? t("car.provider.refreshing") : t("car.provider.refresh") }}
</button>
<button v-if="canWrite" class="dh-btn dh-btn-ghost !px-3 !py-1.5 text-danger" @click="disconnect">
{{ t("car.provider.unlink") }}
</button>
</div>
</div>
<p v-if="loading && !snap" class="text-muted">{{ t("common.loading") }}</p>
<template v-else-if="snap">
<!-- The provider can't be reached for this car: say why, don't fail. -->
<div v-if="snap.unavailable" class="rounded-card border border-dashed border-default p-8 text-center">
<p class="text-sm text-warning">{{ snap.detail }}</p>
<p class="mt-2 text-xs text-muted">{{ t("car.provider.own") }}</p>
</div>
<template v-else>
<!-- Headline readings -->
<div v-if="snap.metrics?.length" class="dh-card mb-4 p-6">
<p class="eyebrow mb-3">{{ t("car.provider.readings") }}</p>
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div v-for="m in snap.metrics" :key="m.key">
<dt class="eyebrow">{{ metricLabel(m.key) }}</dt>
<dd class="mt-0.5 data text-lg font-bold text-strong">
{{ m.value }}<span v-if="m.unit" class="ml-1 text-sm font-medium text-muted">{{ m.unit }}</span>
</dd>
</div>
</dl>
</div>
<!-- The provider's odometer is ahead of the stored one: offer to take it. -->
<div
v-if="snap.suggestedCurrentKm && canWrite"
class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-control bg-sunken px-4 py-3">
<p class="text-sm text-body">
{{ t("car.provider.odometerSuggest", { label, km: formatKm(snap.suggestedCurrentKm) }) }}
</p>
<button class="dh-btn dh-btn-primary !px-3 !py-1.5" :disabled="syncing" @click="applyOdometer">
{{ syncing ? t("car.provider.updatingOdometer") : t("car.provider.updateOdometer") }}
</button>
</div>
<!-- The vehicle record itself -->
<div v-if="snap.vehicle" class="dh-card mb-4 p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="eyebrow">{{ t("car.provider.vehicle") }}</p>
<p class="mt-0.5 font-bold text-strong">{{ snap.vehicle.name }}</p>
<p class="text-sm text-muted">
{{ [snap.vehicle.make, snap.vehicle.model, snap.vehicle.year || ''].filter(Boolean).join(' ') }}
</p>
<p v-if="snap.vehicle.vin" class="data mt-0.5 text-xs text-muted">{{ snap.vehicle.vin }}</p>
</div>
<img
v-if="snap.vehicle.imageUrl"
:src="snap.vehicle.imageUrl"
alt=""
class="h-20 max-w-full rounded-control object-contain"
/>
</div>
<details v-if="snap.vehicle.fields?.length" class="mt-4">
<summary class="cursor-pointer text-xs font-semibold text-brandtext hover:underline">
{{ t("car.provider.allFields") }}
</summary>
<dl class="mt-3 divide-y divide-subtle text-sm">
<div v-for="f in snap.vehicle.fields" :key="f.key" class="flex gap-4 py-1.5">
<dt class="data w-1/2 shrink-0 break-all text-xs text-muted">{{ f.key }}</dt>
<dd class="min-w-0 break-words text-body">{{ f.value }}</dd>
</div>
</dl>
</details>
</div>
<!-- One card per capability the plugin exposes -->
<div class="space-y-4">
<div v-for="sec in sections" :key="sec.id" class="dh-card p-6">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="font-semibold text-strong">{{ sectionLabel(sec.id) }}</h3>
<span v-if="sec.status === 'error'" class="dh-badge dh-badge-warning">{{ sec.error }}</span>
</div>
<p v-if="sec.status === 'empty'" class="text-sm text-muted">{{ t("car.provider.sectionEmpty") }}</p>
<template v-else-if="sec.status === 'ok'">
<dl class="divide-y divide-subtle text-sm">
<div v-for="f in sec.fields" :key="f.key" class="flex gap-4 py-1.5">
<dt class="data w-1/2 shrink-0 break-all text-xs text-muted">{{ f.key }}</dt>
<dd class="min-w-0 break-words text-body">{{ f.value }}</dd>
</div>
</dl>
<p v-if="sec.truncated" class="mt-2 text-xs text-warning">
{{ t("car.provider.truncated", { n: sec.fields.length }) }}
</p>
<details v-if="sec.raw" class="mt-3">
<summary class="cursor-pointer text-xs font-semibold text-brandtext hover:underline">
{{ t("car.provider.raw") }}
</summary>
<pre class="mt-2 max-h-96 overflow-auto rounded-control bg-sunken p-3 text-xs text-body">{{ prettyJSON(sec.raw) }}</pre>
</details>
</template>
</div>
</div>
<p class="mt-4 text-xs text-muted">
{{ t("car.provider.updated", { time: formatDateTime(snap.fetchedAt) }) }} · {{ t("car.provider.own") }}
</p>
</template>
</template>
</template>
<!-- Not linked: offer to connect this car to a vehicle -->
<template v-else>
<div class="dh-card p-6">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">{{ t("car.provider.connectTitle") }}</h2>
<p v-if="connectable.length === 0" class="mt-2 text-sm text-muted">{{ t("car.provider.noProviders") }}</p>
<RouterLink
v-if="connectable.length === 0"
to="/settings"
class="mt-3 inline-block text-sm font-medium text-brandtext hover:underline">
{{ t("car.provider.settingsLink") }}
</RouterLink>
<template v-else>
<p class="mt-1 text-sm text-muted">{{ t("car.provider.connectHint") }}</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div v-if="connectable.length > 1">
<label class="dh-label">{{ t("forms.import.service") }}</label>
<select v-model="linkProvider" class="dh-input">
<option v-for="p in connectable" :key="p.id" :value="p.id">{{ p.label }}</option>
</select>
</div>
<div>
<label class="dh-label">{{ t("forms.import.selectVehicle") }}</label>
<p v-if="linkLoading" class="text-sm text-muted">{{ t("forms.import.loadingVehicles") }}</p>
<p v-else-if="linkVehicles.length === 0" class="text-sm text-muted">{{ t("forms.import.noVehicles") }}</p>
<select v-else v-model="linkVehicleId" class="dh-input">
<option v-for="v in linkVehicles" :key="v.id" :value="v.id">
{{ v.name }}{{ v.vin ? " · " + v.vin : "" }}
</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
v-if="canWrite"
class="dh-btn dh-btn-primary"
:disabled="!linkVehicleId || linking"
@click="connect">
{{ linking ? t("car.provider.connecting") : t("car.provider.connectSubmit") }}
</button>
</div>
</template>
</div>
</template>
</section>
</template>
+70
View File
@@ -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",
+70
View File
@@ -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",
+70
View File
@@ -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",
+12
View File
@@ -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.
+45 -1
View File
@@ -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);
</button>
</div>
<!-- Connected service (MyToyota, ). Mounted only when its tab is open, so
opening a car never triggers a login against the manufacturer. -->
<ProviderPanel
v-if="activeTab === 'provider'"
:car="car"
:can-write="canWrite"
:provider-label="car.provider ? providerLabel : ''"
@car-updated="onCarUpdated"
/>
<!-- Information -->
<section v-if="activeTab === 'info'">
<section v-else-if="activeTab === 'info'">
<div class="dh-card p-6">
<dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
<div><dt class="eyebrow">{{ t("car.info.oilSpec") }}</dt><dd class="mt-0.5 font-medium text-strong">{{ car.oilSpec || t("common.empty") }}</dd></div>
+39 -5
View File
@@ -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(() => {});
});
</script>
<template>
@@ -66,10 +88,16 @@ onMounted(load);
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("dashboard.title") }}</h1>
<p class="mt-1 text-sm text-muted">{{ t("dashboard.subtitle") }}</p>
</div>
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
{{ t("dashboard.addCar") }}
</button>
<div class="flex flex-wrap items-center gap-2">
<button v-if="canImport" class="dh-btn dh-btn-ghost" @click="showImport = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0-4-4m4 4 4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" /></svg>
{{ t("dashboard.importCar") }}
</button>
<button class="dh-btn dh-btn-primary" @click="showAdd = true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
{{ t("dashboard.addCar") }}
</button>
</div>
</div>
<p v-if="error" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ error }}</p>
@@ -141,5 +169,11 @@ onMounted(load);
</div>
<CarFormModal v-if="showAdd" @saved="onSaved" @close="showAdd = false" />
<CarImportModal
v-if="showImport"
@saved="onImported"
@open-car="(id) => router.push({ name: 'car', params: { id } })"
@close="showImport = false"
/>
</div>
</template>