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:
tajniak81
2026-08-29 19:40:31 +02:00
co-authored by Claude Opus 5
parent a203414ceb
commit 22a22ec43a
9 changed files with 90 additions and 10 deletions
@@ -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":
@@ -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