From e7d2ef84cdd55c5855fa2c965b771e812cd2af88 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:11:02 +0200 Subject: [PATCH] Toyota: the electric read moves too, and a plug-in hybrid gets its fuel pytoyoda went from 5.2.7 to 6.0.0. Two changes cross over to a connector that returns raw JSON. The electric status read was the one route left on /v1/global/remote after the mid-2026 retirement. Toyota has now fenced it the same way, and the app reads the same data from /v1/vehicle/electric/status; that is where the electric capability now points. Upstream took the route from a capture of the app and has proved it only with unit tests, so the old route is kept: when the new one answers 403 or 404, the read is tried once on the old one before it fails. Upstream also widened the electric gate from fuelType "E" to "E" or "I", since "I" is Toyota's code for a plug-in hybrid. The car importer passed the code through the generic normalizer, which knows neither letter, so an imported Toyota came in without a fuel type. "E" now imports as electric and "I" as hybrid; other letters stay unset because nothing pins them down. The plugin is now 0.4.0. Left where they were: phevUsableBatteryLevel, a new field on the electric payload, already reaches the card because the payload is passed through whole. The trip list has stopped returning routes, which are now one call per trip on /v1/trips/{id}; this connector has never read trips. Co-Authored-By: Claude Opus 5.5 --- .../internal/api/vehicleproviders_test.go | 15 ++++++++++++ .../internal/api/vehicleproviders_toyota.go | 16 ++++++++++++- .../internal/plugins/builtin/toyota/toyota.go | 24 +++++++++++++++---- .../plugins/builtin/toyota/toyota_test.go | 15 +++++++++--- 4 files changed, 61 insertions(+), 9 deletions(-) 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) + } } }