Files
DriverVault/API Server/internal/api/vehicleproviders_toyota.go
T
tajniak81andClaude Opus 5 358ee68f94 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>
2026-08-17 14:13:11 +02:00

244 lines
8.4 KiB
Go

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
}