Files
DriverVault/API Server/internal/api/vehicleproviders_toyota.go
T
tajniak81andClaude Opus 5 a26c608fc3 Toyota: the Subaru realm, and the header the app now sends everywhere
pytoyoda moved from 5.2.0 to 5.2.7 in the four days before this, and most of
it is Pydantic defaults and pagination that a connector returning raw JSON
never had. Two things cross over.

The Solterra is a Toyota underneath and lives on the same Connected Europe
backend, but a Subaru account does not log in as the MyToyota app: it has its
own ForgeRock realm (alliance-subaru), its own OAuth client and redirect, and
its own client credentials, on the same host. The four login URLs that were
constants become a brandAuth chosen at Init — Toyota and Lexus keep the tme
realm and the oneapp client, Subaru gets its own — and the three URLs are
derived from the parts that differ, because the shape is ForgeRock's and does
not vary by realm. Past the login nothing changes: same data API, same
endpoints, same headers, which is why it is a third option on the connector
rather than a fourth connector. A brand on the wrong realm authenticates
against a directory it is not in and reads as a bad password, so a test pins
each brand to its realm and client.

The app now sends x-user-region on every call. The gateway only insists on it
for remote commands on Suzuki-badged DCM24 cars, which this connector never
issues; but a read that is identical to the app's is cheaper to keep than one
that is deliberately distinct, so it goes out too. The x-appbrand/brand pair
that Lexus already sent goes out for Subaru as well; a Toyota account still
sends neither.

The brand is chosen in three places that do not read the descriptor — the Web
App and Phone App settings forms hard-code their options — so Subaru is added
to both, and to the six translation files behind them. The car importer
expands "S" to Subaru so an imported Solterra gets its make.

Left where they were: the trips endpoint, which predates the last sync and
wants a date range and a card, and the wake calls, which a read-only
connector does not make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:28:50 +02:00

248 lines
8.5 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: "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"/"S") 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"
case "S", "SUBARU":
return "Subaru"
}
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
}