From 22a22ec43a86883f0fa30d54d6010d3bcd82285e Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:40:31 +0200 Subject: [PATCH] Toyota: the status route the car answers, not the one it retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toyota put the /v1/global/remote read routes behind AWS SigV4 in mid-2026. A bearer token is no longer a credential there, so the doors-and-windows card has been asking a gateway that answers 403 — the one section of the provider tab that could only ever have been in error. The MyToyota app reads that state from /v1/vehicle/status now and pytoyoda followed it in 5.2.0; so does the connector. The electric route did not move, and the comment above the endpoint block says which of the two namespaces each one lives in, because the obvious tidy — sweep the rest onto /v1/vehicle/* — would break the ones that still work. The same migration gave the climate reads a home worth porting: /v1/vehicle/ climate-status is what the cabin is doing, climate-settings the preset it was told to do it at. Both are GETs with a vin, both are new cards on the tab, and their headings are in all three languages on both apps. Nothing about the tab's plumbing changed to hold them — a section is an id, an action, and whatever JSON comes back, which is the point of that shape. Left where they are: the POST wake calls. Upstream refreshes a stale reading by waking the modem, and this connector is documented as read-only, so climate and status show what the car last reported rather than what it would say if asked twice. The cost is a reading that can be hours old, and it is the honest one to pay for a connector that promises not to touch the vehicle. Two tests keep the migration from being undone by hand: one fails if any advertised capability points back at a retired route, the other if a capability is advertised without being wired into Invoke, which is the way the next endpoint would go missing. Co-Authored-By: Claude Opus 5 --- .../internal/api/vehicleproviders_toyota.go | 2 + .../internal/plugins/builtin/toyota/toyota.go | 36 +++++++++---- .../plugins/builtin/toyota/toyota_test.go | 50 +++++++++++++++++++ Phone App/assets/i18n/da.json | 2 + Phone App/assets/i18n/en.json | 2 + Phone App/assets/i18n/pl.json | 2 + Web App/web/src/i18n/da.json | 2 + Web App/web/src/i18n/en.json | 2 + Web App/web/src/i18n/pl.json | 2 + 9 files changed, 90 insertions(+), 10 deletions(-) diff --git a/API Server/internal/api/vehicleproviders_toyota.go b/API Server/internal/api/vehicleproviders_toyota.go index afc4713..de9e33b 100644 --- a/API Server/internal/api/vehicleproviders_toyota.go +++ b/API Server/internal/api/vehicleproviders_toyota.go @@ -31,6 +31,8 @@ func (toyotaSource) sections() []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"}, diff --git a/API Server/internal/plugins/builtin/toyota/toyota.go b/API Server/internal/plugins/builtin/toyota/toyota.go index fd955a3..0cb381a 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota.go +++ b/API Server/internal/plugins/builtin/toyota/toyota.go @@ -8,7 +8,9 @@ // - Europe only. Other Toyota regions use a different backend and are not // supported. // - Read-only. Only vehicle information is retrieved; no remote control -// (lock, climate, charging) commands are implemented. +// (lock, climate, charging) commands are implemented, and neither are the +// POST wake calls upstream uses to refresh a stale reading — climate and +// status are read as the car last reported them. // - Unofficial. This talks to a private API with hardcoded app credentials // extracted from the MyToyota app; Toyota may change or break it at any time. // @@ -64,15 +66,23 @@ const ( ) // Read-only API endpoints exposed as capabilities. +// +// 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. const ( - epVehicleGUID = "/v2/vehicle/guid" - epLocation = "/v1/location" - epHealthStatus = "/v1/vehiclehealth/status" - epRemoteStatus = "/v1/global/remote/status" - epElectricStatus = "/v1/global/remote/electric/status" - epTelemetry = "/v3/telemetry" - epNotifications = "/v2/notification/history" - epServiceHistory = "/v1/servicehistory/vehicle/summary" + epVehicleGUID = "/v2/vehicle/guid" + epLocation = "/v1/location" + epHealthStatus = "/v1/vehiclehealth/status" + epRemoteStatus = "/v1/vehicle/status" + epElectricStatus = "/v1/global/remote/electric/status" + epClimateStatus = "/v1/vehicle/climate-status" + epClimateSettings = "/v1/vehicle/climate-settings" + epTelemetry = "/v3/telemetry" + epNotifications = "/v2/notification/history" + epServiceHistory = "/v1/servicehistory/vehicle/summary" ) // tokenExpiryMargin is subtracted from the reported token lifetime so a request @@ -112,7 +122,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { return plugins.Descriptor{ Name: "toyota", Provider: "Toyota Connected Europe", - Version: "0.1.0", + Version: "0.2.0", Kind: plugins.KindBuiltin, Category: plugins.CategoryAPIsExternal, AuthType: plugins.AuthOAuth2, @@ -123,6 +133,8 @@ func (p *Plugin) Descriptor() plugins.Descriptor { {ID: "health", Method: "GET", Endpoint: epHealthStatus, Description: "Vehicle health / dashboard warning lights for a VIN."}, {ID: "status", Method: "GET", Endpoint: epRemoteStatus, Description: "General remote status (doors, windows, lights) for a VIN."}, {ID: "electric", Method: "GET", Endpoint: epElectricStatus, Description: "Battery level, EV range and charging status for a VIN."}, + {ID: "climate", Method: "GET", Endpoint: epClimateStatus, Description: "Current climate state (cabin temperature, whether it is running) for a VIN."}, + {ID: "climate-settings", Method: "GET", Endpoint: epClimateSettings, Description: "Stored climate preset (target temperature, seat and mirror heating) for a VIN."}, {ID: "notifications", Method: "GET", Endpoint: epNotifications, Description: "Notification history for a VIN."}, {ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."}, }, @@ -215,6 +227,10 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa endpoint = epRemoteStatus case "electric": endpoint = epElectricStatus + case "climate": + endpoint = epClimateStatus + case "climate-settings": + endpoint = epClimateSettings case "notifications": endpoint = epNotifications case "service-history": diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go index 851ce04..cd5a6f1 100644 --- a/API Server/internal/plugins/builtin/toyota/toyota_test.go +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -41,6 +41,56 @@ func TestDescriptor(t *testing.T) { } } +// TestEndpointsFollowUpstream guards the mid-2026 migration: Toyota put the +// /v1/global/remote read routes behind AWS SigV4, where a Bearer token is +// answered with 403, and the app moved to /v1/vehicle/*. Falling back to the old +// paths would leave the cards permanently in error. +func TestEndpointsFollowUpstream(t *testing.T) { + retired := []string{ + "/v1/global/remote/status", + "/v1/global/remote/climate-status", + "/v1/global/remote/climate-settings", + } + for _, c := range (&Plugin{}).Descriptor().Capabilities { + for _, dead := range retired { + if c.Endpoint == dead { + t.Errorf("capability %q still points at the retired %s", c.ID, dead) + } + } + } + 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) + } +} + +// TestActionsCoverCapabilities keeps Invoke and the descriptor in step: every +// advertised capability must be callable, since the car tab drives the plugin by +// the ids the descriptor publishes. +func TestActionsCoverCapabilities(t *testing.T) { + p := &Plugin{} + _ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p"}) + + for _, c := range p.Descriptor().Capabilities { + if c.ID == "vehicles" { + continue // the only action that takes no vin + } + // No vin, so the call is rejected before any network I/O — an "unknown + // action" here means the switch in Invoke was never extended. + _, err := p.Invoke(context.Background(), c.ID, nil) + if err == nil { + t.Errorf("action %q: expected an error without a vin", c.ID) + continue + } + if strings.Contains(err.Error(), "unknown action") { + t.Errorf("capability %q is advertised but not wired into Invoke", c.ID) + } + } +} + func TestRegistered(t *testing.T) { // The plugin must self-register via init() so the manager can construct it. var found bool diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 8db3a35..f77a170 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -438,6 +438,8 @@ "telemetry": "Kilometerstand og rækkevidde", "electric": "Batteri og opladning", "status": "Døre, ruder og lys", + "climate": "Klimaanlæg", + "climateSettings": "Klimaindstillinger", "health": "Køretøjets tilstand", "location": "Sidst kendte position", "serviceHistory": "Servicehistorik hos forhandler", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 35ed15f..e67b3d3 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -438,6 +438,8 @@ "telemetry": "Odometer & range", "electric": "Battery & charging", "status": "Doors, windows & lights", + "climate": "Climate", + "climateSettings": "Climate presets", "health": "Vehicle health", "location": "Last known location", "serviceHistory": "Dealer service history", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index ff00fdb..b617f20 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -442,6 +442,8 @@ "telemetry": "Przebieg i zasięg", "electric": "Akumulator i ładowanie", "status": "Drzwi, szyby i światła", + "climate": "Klimatyzacja", + "climateSettings": "Ustawienia klimatyzacji", "health": "Stan pojazdu", "location": "Ostatnia znana lokalizacja", "serviceHistory": "Historia serwisowa u dealera", diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 0a60ad6..d8a809a 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -419,6 +419,8 @@ "telemetry": "Kilometerstand og rækkevidde", "electric": "Batteri og opladning", "status": "Døre, ruder og lys", + "climate": "Klimaanlæg", + "climateSettings": "Klimaindstillinger", "health": "Køretøjets tilstand", "location": "Sidst kendte position", "serviceHistory": "Servicehistorik hos forhandler", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 07cb2f9..823d265 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -418,6 +418,8 @@ "telemetry": "Odometer & range", "electric": "Battery & charging", "status": "Doors, windows & lights", + "climate": "Climate", + "climateSettings": "Climate presets", "health": "Vehicle health", "location": "Last known location", "serviceHistory": "Dealer service history", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 1b4f54d..5df9625 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -423,6 +423,8 @@ "telemetry": "Przebieg i zasięg", "electric": "Akumulator i ładowanie", "status": "Drzwi, szyby i światła", + "climate": "Klimatyzacja", + "climateSettings": "Ustawienia klimatyzacji", "health": "Stan pojazdu", "location": "Ostatnia znana lokalizacja", "serviceHistory": "Historia serwisowa u dealera",