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: "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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user