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>
262 lines
9.0 KiB
Go
262 lines
9.0 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: 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"),
|
|
}
|
|
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"/"S") 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"
|
|
case "S", "SUBARU":
|
|
return "Subaru"
|
|
}
|
|
b := strings.TrimSpace(brand)
|
|
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 {
|
|
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
|
|
}
|