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: "climate", Action: "climate"}, {ID: "climateSettings", Action: "climate-settings"}, {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 }