Files
tajniak81andClaude Opus 5 22a22ec43a 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>
2026-08-29 19:40:31 +02:00

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
}