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>
246 lines
8.5 KiB
Go
246 lines
8.5 KiB
Go
package api
|
|
|
|
// toyotaSource is the first vehicleSource: Toyota Connected Europe, the backend
|
|
// behind the MyToyota app (see internal/plugins/builtin/toyota). It contributes
|
|
// nothing but wiring — credentials come from the cascade already in
|
|
// integrations.go, and the payload walking lives in vehicleproviders.go — which
|
|
// is the shape the next manufacturer's adapter should copy.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
)
|
|
|
|
// toyotaLabel is what the user sees: the app they know the account by, not the
|
|
// corporate service name behind it. It titles the car's provider tab.
|
|
const toyotaLabel = "MyToyota"
|
|
|
|
type toyotaSource struct{}
|
|
|
|
func (toyotaSource) id() string { return toyotaPlugin }
|
|
func (toyotaSource) label() string { return toyotaLabel }
|
|
func (toyotaSource) service() string { return "Toyota Connected Europe" }
|
|
|
|
func (toyotaSource) listAction() string { return "vehicles" }
|
|
|
|
// sections are the plugin's per-VIN read-only capabilities, in the order the tab
|
|
// shows them: the live readings first, then the history.
|
|
func (toyotaSource) sections() []providerSection {
|
|
return []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"},
|
|
{ID: "notifications", Action: "notifications"},
|
|
}
|
|
}
|
|
|
|
// gate resolves the caller's MyToyota credentials through the global → org → user
|
|
// cascade and reports what is missing when they cannot be used. It is the single
|
|
// place those messages are written: handleToyotaVehicles calls it too.
|
|
func (toyotaSource) gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (map[string]string, bool, string) {
|
|
res := s.resolveToyota(ctx, who, userRaw)
|
|
switch {
|
|
case !res.available:
|
|
return nil, false, "The Toyota integration is disabled by the administrator"
|
|
case !res.orgEnabled:
|
|
return nil, false, "The Toyota integration is disabled for your organization"
|
|
case !res.enabled:
|
|
return nil, false, "Enable the Toyota integration in Settings to load your vehicles"
|
|
case strings.TrimSpace(res.eff.Username) == "" || strings.TrimSpace(res.eff.Password) == "":
|
|
return nil, false, "Enter your MyToyota email and password to connect"
|
|
}
|
|
return map[string]string{
|
|
"username": res.eff.Username,
|
|
"password": res.eff.Password,
|
|
"brand": res.eff.Brand,
|
|
}, true, ""
|
|
}
|
|
|
|
// vehicles normalizes the /v2/vehicle/guid payload. Every field is looked up by
|
|
// name rather than by path (see findString in vehicleproviders.go), because this
|
|
// is an unofficial API: a renamed or moved key costs one blank field instead of a
|
|
// broken import, and the whole object still reaches the UI via Fields/Raw.
|
|
func (toyotaSource) vehicles(raw json.RawMessage) []providerVehicle {
|
|
objects := vehicleObjects(raw)
|
|
out := make([]providerVehicle, 0, len(objects))
|
|
|
|
for _, obj := range objects {
|
|
vin := findString(obj, "vin")
|
|
id := vin
|
|
if id == "" {
|
|
id = findString(obj, "vehicleId", "guid", "id")
|
|
}
|
|
if id == "" {
|
|
continue // nothing stable to address it by; skip rather than guess
|
|
}
|
|
|
|
v := providerVehicle{
|
|
ID: id,
|
|
VIN: vin,
|
|
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")),
|
|
BuildDate: isoDateOnly(findString(obj, "productionDate", "manufacturingDate", "buildDate")),
|
|
FirstRegistrationDate: isoDateOnly(findString(obj, "firstRegistrationDate", "initialRegistrationDate", "registrationDate")),
|
|
ImageURL: findString(obj, "imageUrl", "carImageUrl", "image", "picture"),
|
|
}
|
|
if year, ok := findInt(obj, "modelYear", "productionYear", "year"); ok && year > 1900 && year < 2200 {
|
|
v.Year = year
|
|
}
|
|
v.Name = vehicleDisplayName(obj, v)
|
|
|
|
if b, err := json.Marshal(obj); err == nil {
|
|
v.Raw = b
|
|
v.Fields, _ = flattenJSON(obj, maxSectionFields)
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toyotaMake expands the one-letter brand code the app uses on the wire ("T"/"L")
|
|
// and otherwise passes the reported brand through in title case.
|
|
func toyotaMake(brand string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(brand)) {
|
|
case "T", "TOYOTA", "":
|
|
return "Toyota"
|
|
case "L", "LEXUS":
|
|
return "Lexus"
|
|
}
|
|
b := strings.TrimSpace(brand)
|
|
return strings.ToUpper(b[:1]) + strings.ToLower(b[1:])
|
|
}
|
|
|
|
// 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 {
|
|
if alias := findString(obj, "alias", "nickName", "displayName", "vehicleName"); alias != "" {
|
|
return alias
|
|
}
|
|
if name := strings.TrimSpace(v.Make + " " + v.Model); name != "" {
|
|
return name
|
|
}
|
|
return v.ID
|
|
}
|
|
|
|
// vehicleObjects digs the list of vehicle objects out of a plugin payload,
|
|
// accepting the shapes these APIs use interchangeably: a bare array, an
|
|
// envelope with a "payload" array, an envelope wrapping a single object, or an
|
|
// array nested somewhere else entirely.
|
|
func vehicleObjects(raw json.RawMessage) []map[string]any {
|
|
var tree any
|
|
if json.Unmarshal(raw, &tree) != nil {
|
|
return nil
|
|
}
|
|
|
|
if node, ok := findNode(tree, "payload", "vehicles", "items", "data"); ok {
|
|
if objs := asObjectSlice(node); objs != nil {
|
|
return objs
|
|
}
|
|
}
|
|
if objs := asObjectSlice(tree); objs != nil {
|
|
return objs
|
|
}
|
|
return firstObjectSlice(tree)
|
|
}
|
|
|
|
// asObjectSlice reads a node as a list of objects, treating a lone object as a
|
|
// one-element list.
|
|
func asObjectSlice(node any) []map[string]any {
|
|
switch v := node.(type) {
|
|
case []any:
|
|
out := make([]map[string]any, 0, len(v))
|
|
for _, e := range v {
|
|
if m, ok := e.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
case map[string]any:
|
|
return []map[string]any{v}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// firstObjectSlice finds the outermost array of objects anywhere in a tree — the
|
|
// last resort when the envelope key is not one we know.
|
|
func firstObjectSlice(root any) []map[string]any {
|
|
queue, visited := []any{root}, 0
|
|
for len(queue) > 0 && visited < maxWalkNodes {
|
|
node := queue[0]
|
|
queue = queue[1:]
|
|
visited++
|
|
|
|
if arr, ok := node.([]any); ok {
|
|
if objs := asObjectSlice(arr); objs != nil {
|
|
return objs
|
|
}
|
|
}
|
|
switch v := node.(type) {
|
|
case map[string]any:
|
|
for _, k := range sortedKeys(v) {
|
|
queue = append(queue, v[k])
|
|
}
|
|
case []any:
|
|
queue = append(queue, v...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeProviderFuelType maps whatever a provider calls a powertrain onto the
|
|
// app's fuel_type enum. Substring matching on purpose: the same drivetrain
|
|
// arrives as "HV", "hybrid", "Hybrid Electric" and "PETROL_HYBRID" depending on
|
|
// the endpoint. An unrecognized value yields "" so the field is simply left
|
|
// unset rather than written wrong.
|
|
func normalizeProviderFuelType(v string) string {
|
|
s := strings.ToLower(strings.TrimSpace(v))
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
switch {
|
|
case strings.Contains(s, "hydrogen"), strings.Contains(s, "fcev"), strings.Contains(s, "fuelcell"):
|
|
return "hydrogen"
|
|
// Plug-in and mild hybrids both run on petrol too, so hybrid is checked
|
|
// before the bare fuels — "petrol hybrid" must not land on "petrol".
|
|
case strings.Contains(s, "hybrid"), s == "hv", s == "phev", s == "mhev", strings.Contains(s, "phv"):
|
|
return "hybrid"
|
|
case strings.Contains(s, "diesel"):
|
|
return "diesel"
|
|
case strings.Contains(s, "electric"), s == "ev", s == "bev":
|
|
return "electric"
|
|
case strings.Contains(s, "petrol"), strings.Contains(s, "gasoline"), s == "gas":
|
|
return "petrol"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isoDateOnly reduces a provider timestamp to the YYYY-MM-DD the car's date
|
|
// fields store. Anything that is not already an ISO date is dropped, rather than
|
|
// guessed at: "01/06/2015" is June or January depending on who wrote it.
|
|
func isoDateOnly(v string) string {
|
|
s := strings.TrimSpace(v)
|
|
if len(s) < 10 || s[4] != '-' || s[7] != '-' {
|
|
return ""
|
|
}
|
|
head := s[:10]
|
|
for i, r := range head {
|
|
if i == 4 || i == 7 {
|
|
continue
|
|
}
|
|
if r < '0' || r > '9' {
|
|
return ""
|
|
}
|
|
}
|
|
return head
|
|
}
|