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 <noreply@anthropic.com>
This commit is contained in:
1 parent
143917f0bb
commit
e7d2ef84cd
4 files changed
+61
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user