Toyota: the status route the car answers, not the one it retired
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a203414ceb
commit
22a22ec43a
@@ -31,6 +31,8 @@ func (toyotaSource) sections() []providerSection {
|
|||||||
{ID: "telemetry", Action: "telemetry"},
|
{ID: "telemetry", Action: "telemetry"},
|
||||||
{ID: "electric", Action: "electric"},
|
{ID: "electric", Action: "electric"},
|
||||||
{ID: "status", Action: "status"},
|
{ID: "status", Action: "status"},
|
||||||
|
{ID: "climate", Action: "climate"},
|
||||||
|
{ID: "climateSettings", Action: "climate-settings"},
|
||||||
{ID: "health", Action: "health"},
|
{ID: "health", Action: "health"},
|
||||||
{ID: "location", Action: "location"},
|
{ID: "location", Action: "location"},
|
||||||
{ID: "serviceHistory", Action: "service-history"},
|
{ID: "serviceHistory", Action: "service-history"},
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
// - Europe only. Other Toyota regions use a different backend and are not
|
// - Europe only. Other Toyota regions use a different backend and are not
|
||||||
// supported.
|
// supported.
|
||||||
// - Read-only. Only vehicle information is retrieved; no remote control
|
// - 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
|
// - 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.
|
// 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.
|
// 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 (
|
const (
|
||||||
epVehicleGUID = "/v2/vehicle/guid"
|
epVehicleGUID = "/v2/vehicle/guid"
|
||||||
epLocation = "/v1/location"
|
epLocation = "/v1/location"
|
||||||
epHealthStatus = "/v1/vehiclehealth/status"
|
epHealthStatus = "/v1/vehiclehealth/status"
|
||||||
epRemoteStatus = "/v1/global/remote/status"
|
epRemoteStatus = "/v1/vehicle/status"
|
||||||
epElectricStatus = "/v1/global/remote/electric/status"
|
epElectricStatus = "/v1/global/remote/electric/status"
|
||||||
epTelemetry = "/v3/telemetry"
|
epClimateStatus = "/v1/vehicle/climate-status"
|
||||||
epNotifications = "/v2/notification/history"
|
epClimateSettings = "/v1/vehicle/climate-settings"
|
||||||
epServiceHistory = "/v1/servicehistory/vehicle/summary"
|
epTelemetry = "/v3/telemetry"
|
||||||
|
epNotifications = "/v2/notification/history"
|
||||||
|
epServiceHistory = "/v1/servicehistory/vehicle/summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tokenExpiryMargin is subtracted from the reported token lifetime so a request
|
// tokenExpiryMargin is subtracted from the reported token lifetime so a request
|
||||||
@@ -112,7 +122,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
|||||||
return plugins.Descriptor{
|
return plugins.Descriptor{
|
||||||
Name: "toyota",
|
Name: "toyota",
|
||||||
Provider: "Toyota Connected Europe",
|
Provider: "Toyota Connected Europe",
|
||||||
Version: "0.1.0",
|
Version: "0.2.0",
|
||||||
Kind: plugins.KindBuiltin,
|
Kind: plugins.KindBuiltin,
|
||||||
Category: plugins.CategoryAPIsExternal,
|
Category: plugins.CategoryAPIsExternal,
|
||||||
AuthType: plugins.AuthOAuth2,
|
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: "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: "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: "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: "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."},
|
{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
|
endpoint = epRemoteStatus
|
||||||
case "electric":
|
case "electric":
|
||||||
endpoint = epElectricStatus
|
endpoint = epElectricStatus
|
||||||
|
case "climate":
|
||||||
|
endpoint = epClimateStatus
|
||||||
|
case "climate-settings":
|
||||||
|
endpoint = epClimateSettings
|
||||||
case "notifications":
|
case "notifications":
|
||||||
endpoint = epNotifications
|
endpoint = epNotifications
|
||||||
case "service-history":
|
case "service-history":
|
||||||
|
|||||||
@@ -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) {
|
func TestRegistered(t *testing.T) {
|
||||||
// The plugin must self-register via init() so the manager can construct it.
|
// The plugin must self-register via init() so the manager can construct it.
|
||||||
var found bool
|
var found bool
|
||||||
|
|||||||
@@ -438,6 +438,8 @@
|
|||||||
"telemetry": "Kilometerstand og rækkevidde",
|
"telemetry": "Kilometerstand og rækkevidde",
|
||||||
"electric": "Batteri og opladning",
|
"electric": "Batteri og opladning",
|
||||||
"status": "Døre, ruder og lys",
|
"status": "Døre, ruder og lys",
|
||||||
|
"climate": "Klimaanlæg",
|
||||||
|
"climateSettings": "Klimaindstillinger",
|
||||||
"health": "Køretøjets tilstand",
|
"health": "Køretøjets tilstand",
|
||||||
"location": "Sidst kendte position",
|
"location": "Sidst kendte position",
|
||||||
"serviceHistory": "Servicehistorik hos forhandler",
|
"serviceHistory": "Servicehistorik hos forhandler",
|
||||||
|
|||||||
@@ -438,6 +438,8 @@
|
|||||||
"telemetry": "Odometer & range",
|
"telemetry": "Odometer & range",
|
||||||
"electric": "Battery & charging",
|
"electric": "Battery & charging",
|
||||||
"status": "Doors, windows & lights",
|
"status": "Doors, windows & lights",
|
||||||
|
"climate": "Climate",
|
||||||
|
"climateSettings": "Climate presets",
|
||||||
"health": "Vehicle health",
|
"health": "Vehicle health",
|
||||||
"location": "Last known location",
|
"location": "Last known location",
|
||||||
"serviceHistory": "Dealer service history",
|
"serviceHistory": "Dealer service history",
|
||||||
|
|||||||
@@ -442,6 +442,8 @@
|
|||||||
"telemetry": "Przebieg i zasięg",
|
"telemetry": "Przebieg i zasięg",
|
||||||
"electric": "Akumulator i ładowanie",
|
"electric": "Akumulator i ładowanie",
|
||||||
"status": "Drzwi, szyby i światła",
|
"status": "Drzwi, szyby i światła",
|
||||||
|
"climate": "Klimatyzacja",
|
||||||
|
"climateSettings": "Ustawienia klimatyzacji",
|
||||||
"health": "Stan pojazdu",
|
"health": "Stan pojazdu",
|
||||||
"location": "Ostatnia znana lokalizacja",
|
"location": "Ostatnia znana lokalizacja",
|
||||||
"serviceHistory": "Historia serwisowa u dealera",
|
"serviceHistory": "Historia serwisowa u dealera",
|
||||||
|
|||||||
@@ -419,6 +419,8 @@
|
|||||||
"telemetry": "Kilometerstand og rækkevidde",
|
"telemetry": "Kilometerstand og rækkevidde",
|
||||||
"electric": "Batteri og opladning",
|
"electric": "Batteri og opladning",
|
||||||
"status": "Døre, ruder og lys",
|
"status": "Døre, ruder og lys",
|
||||||
|
"climate": "Klimaanlæg",
|
||||||
|
"climateSettings": "Klimaindstillinger",
|
||||||
"health": "Køretøjets tilstand",
|
"health": "Køretøjets tilstand",
|
||||||
"location": "Sidst kendte position",
|
"location": "Sidst kendte position",
|
||||||
"serviceHistory": "Servicehistorik hos forhandler",
|
"serviceHistory": "Servicehistorik hos forhandler",
|
||||||
|
|||||||
@@ -418,6 +418,8 @@
|
|||||||
"telemetry": "Odometer & range",
|
"telemetry": "Odometer & range",
|
||||||
"electric": "Battery & charging",
|
"electric": "Battery & charging",
|
||||||
"status": "Doors, windows & lights",
|
"status": "Doors, windows & lights",
|
||||||
|
"climate": "Climate",
|
||||||
|
"climateSettings": "Climate presets",
|
||||||
"health": "Vehicle health",
|
"health": "Vehicle health",
|
||||||
"location": "Last known location",
|
"location": "Last known location",
|
||||||
"serviceHistory": "Dealer service history",
|
"serviceHistory": "Dealer service history",
|
||||||
|
|||||||
@@ -423,6 +423,8 @@
|
|||||||
"telemetry": "Przebieg i zasięg",
|
"telemetry": "Przebieg i zasięg",
|
||||||
"electric": "Akumulator i ładowanie",
|
"electric": "Akumulator i ładowanie",
|
||||||
"status": "Drzwi, szyby i światła",
|
"status": "Drzwi, szyby i światła",
|
||||||
|
"climate": "Klimatyzacja",
|
||||||
|
"climateSettings": "Ustawienia klimatyzacji",
|
||||||
"health": "Stan pojazdu",
|
"health": "Stan pojazdu",
|
||||||
"location": "Ostatnia znana lokalizacja",
|
"location": "Ostatnia znana lokalizacja",
|
||||||
"serviceHistory": "Historia serwisowa u dealera",
|
"serviceHistory": "Historia serwisowa u dealera",
|
||||||
|
|||||||
Reference in New Issue
Block a user