diff --git a/API Server/internal/api/vehicleproviders_test.go b/API Server/internal/api/vehicleproviders_test.go index 4966b1f..7141186 100644 --- a/API Server/internal/api/vehicleproviders_test.go +++ b/API Server/internal/api/vehicleproviders_test.go @@ -269,6 +269,21 @@ func TestHeadlineMetricsRoundsConvertedReadings(t *testing.T) { } } +func TestToyotaFuelType(t *testing.T) { + cases := map[string]string{ + "E": "electric", // battery EV + "I": "hybrid", // plug-in hybrid + "HV": "hybrid", // spelled out: falls through to the normalizer + "G": "", // a letter nobody has pinned down stays unset + "": "", + } + for in, want := range cases { + if got := toyotaFuelType(in); got != want { + t.Errorf("toyotaFuelType(%q) = %q, want %q", in, got, want) + } + } +} + func TestNormalizeProviderFuelType(t *testing.T) { cases := map[string]string{ "HV": "hybrid", diff --git a/API Server/internal/api/vehicleproviders_toyota.go b/API Server/internal/api/vehicleproviders_toyota.go index e467b50..9912596 100644 --- a/API Server/internal/api/vehicleproviders_toyota.go +++ b/API Server/internal/api/vehicleproviders_toyota.go @@ -86,7 +86,7 @@ func (toyotaSource) vehicles(raw json.RawMessage) []providerVehicle { 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")), + FuelType: toyotaFuelType(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"), @@ -120,6 +120,20 @@ func toyotaMake(brand string) string { return strings.ToUpper(b[:1]) + strings.ToLower(b[1:]) } +// toyotaFuelType reads the one-letter powertrain code the vehicle list sends in +// fuelType: "E" is a battery EV and "I" a plug-in hybrid (pytoyoda gates the +// electric read on exactly these two). Other letters are not pinned down, so +// they and any spelled-out value go through the generic normalizer. +func toyotaFuelType(v string) string { + switch strings.ToUpper(strings.TrimSpace(v)) { + case "E": + return "electric" + case "I": + return "hybrid" + } + return normalizeProviderFuelType(v) +} + // 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 { diff --git a/API Server/internal/plugins/builtin/toyota/toyota.go b/API Server/internal/plugins/builtin/toyota/toyota.go index dcc712e..4f83d9b 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota.go +++ b/API Server/internal/plugins/builtin/toyota/toyota.go @@ -115,19 +115,23 @@ func authFor(brand string) brandAuth { // Toyota retired the /v1/global/remote/{status,climate-*} read routes in mid-2026 // — they now sit behind AWS SigV4 and answer a plain Bearer token with 403 — and // the app reads that state from /v1/vehicle/* instead. epRemoteStatus and the two -// climate endpoints below follow pytoyoda onto the new namespace; the electric -// status route was not moved and stays where it was. +// climate endpoints below follow pytoyoda onto the new namespace. The electric +// status read followed in September 2026 (pytoyoda 6.0.0, from a capture of the +// app); upstream has not proven it against a live car, so epElectricStatusLegacy +// is kept as the fallback when the new route refuses. const ( epVehicleGUID = "/v2/vehicle/guid" epLocation = "/v1/location" epHealthStatus = "/v1/vehiclehealth/status" epRemoteStatus = "/v1/vehicle/status" - epElectricStatus = "/v1/global/remote/electric/status" + epElectricStatus = "/v1/vehicle/electric/status" epClimateStatus = "/v1/vehicle/climate-status" epClimateSettings = "/v1/vehicle/climate-settings" epTelemetry = "/v3/telemetry" epNotifications = "/v2/notification/history" epServiceHistory = "/v1/servicehistory/vehicle/summary" + + epElectricStatusLegacy = "/v1/global/remote/electric/status" ) // tokenExpiryMargin is subtracted from the reported token lifetime so a request @@ -168,7 +172,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { return plugins.Descriptor{ Name: "toyota", Provider: "Toyota Connected Europe", - Version: "0.3.0", + Version: "0.4.0", Kind: plugins.KindBuiltin, Category: plugins.CategoryVehicles, AuthType: plugins.AuthOAuth2, @@ -290,13 +294,23 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa return nil, fmt.Errorf("toyota: action %q requires a vin", action) } - body, _, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil) + body, status, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil) + if err != nil && endpoint == epElectricStatus && electricFallback(status) { + body, _, err = p.apiRequest(ctx, http.MethodGet, epElectricStatusLegacy, pp.VIN, nil) + } if err != nil { return nil, err } return json.RawMessage(body), nil } +// electricFallback reports whether a failed read of the new electric route should +// be retried on the old one: a 403 is the gateway's answer for a route a token +// may not use, a 404 one it does not know. Anything else would fail there too. +func electricFallback(status int) bool { + return status == http.StatusForbidden || status == http.StatusNotFound +} + // Shutdown releases pooled connections. func (p *Plugin) Shutdown(context.Context) error { p.mu.Lock() diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go index 90d9012..91fc562 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota_test.go +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -141,9 +141,18 @@ func TestEndpointsFollowUpstream(t *testing.T) { if epRemoteStatus != "/v1/vehicle/status" { t.Errorf("status endpoint = %q, want /v1/vehicle/status", epRemoteStatus) } - // The electric route was not part of that migration and must stay put. - if epElectricStatus != "/v1/global/remote/electric/status" { - t.Errorf("electric endpoint = %q, want the unmoved global route", epElectricStatus) + // The electric read moved later than the rest, and keeps its old route only + // as the fallback for a gateway that has not caught up. + if epElectricStatus != "/v1/vehicle/electric/status" { + t.Errorf("electric endpoint = %q, want /v1/vehicle/electric/status", epElectricStatus) + } + if epElectricStatusLegacy != "/v1/global/remote/electric/status" { + t.Errorf("electric fallback = %q, want the old global route", epElectricStatusLegacy) + } + for status, want := range map[int]bool{403: true, 404: true, 401: false, 429: false, 500: false, 0: false} { + if got := electricFallback(status); got != want { + t.Errorf("electricFallback(%d) = %v, want %v", status, got, want) + } } }