Files
DriverVault/API Server/internal/plugins/builtin/toyota/toyota.go
T
tajniak81andClaude Opus 5 e648634ce1 The plugin list, grouped by what a plugin actually is
Category has been in the plugin contract since it was written — apis-external,
drives-external, drives-local — and every builtin declared the same one, so it
grouped nothing. Two of the three talk to a wallbox and one talks to a car
manufacturer, and those are different questions an operator arrives with: the
Toyota card is where a driver's account gets linked, the Anker and Greencell
cards are where a charger's broker and credentials live. So vehicles and
chargers join the constants and the three builtins say which they are.

The panel groups on that field rather than on a list of names, which is what
keeps an external plugin from needing panel code. Tab order mirrors the
constants; a category with nothing in it gets no tab, and a single group hides
the bar entirely, so an install with one connector looks exactly as it did.
A category the panel does not recognise — or an empty one — falls to the
external-APIs tab rather than vanishing, because a plugin nobody can see is a
plugin nobody can disable. The selected tab falls back to the first group when
its own goes away, which is what removing the last external plugin does.

Registration still asks only for name, base URL and provider, so a plugin
registered at runtime lands under Other APIs until its manifest names a
category. That path already works and is the honest default: the panel is
guessing about a service it has never spoken to, and the service can say.

The header lockup is the other half. It was a copy of the Web App's mark rather
than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark
against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter
than brand-400. The Web App's Logo.vue moves in verbatim, props included. The
one thing it cannot inherit is which variant to render: the Web App's rail is
always dark, while this panel flips with its own theme toggle, so on-dark is
bound to the theme and the hand-rolled bar fills that existed to survive that
flip are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:30:56 +02:00

701 lines
23 KiB
Go

// Package toyota is a built-in connector for Toyota Connected Europe Services —
// the backend behind the MyToyota app. It is a Go re-implementation of the
// authentication and read-only data flow from the pytoyoda project
// (https://github.com/pytoyoda/pytoyoda), adapted to DriverVault's plugin
// contract.
//
// Scope & limitations (inherited from pytoyoda):
// - 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, 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.
//
// Authentication is a three-legged OAuth2 (ForgeRock/OpenAM) flow:
// 1. POST the /authenticate endpoint in a callback loop, filling in the
// username then password, until a tokenId is returned.
// 2. GET the /authorize endpoint with that tokenId as a cookie; the 302
// redirect carries the authorization code in its Location query string.
// 3. POST the /access_token endpoint to exchange the code for an access +
// refresh + id token. The id token (a JWT) carries the account UUID.
//
// Access tokens are short-lived and refreshed with the refresh token; a full
// re-authentication is the fallback when refresh fails.
package toyota
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"drivervault/apiserver/internal/plugins"
)
// Upstream URLs and hardcoded app credentials. These mirror pytoyoda's const.py
// and are the values baked into the official MyToyota Android app (client
// "oneapp"); they are not user secrets.
const (
apiBaseURL = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io"
accessTokenURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/access_token"
authenticateURL = "https://b2c-login.toyota-europe.com/json/realms/root/realms/tme/authenticate?authIndexType=service&authIndexValue=oneapp"
authorizeURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/authorize?client_id=oneapp&scope=openid+profile+write&response_type=code&redirect_uri=com.toyota.oneapp:/oauth2Callback&code_challenge=plain&code_challenge_method=plain"
redirectURI = "com.toyota.oneapp:/oauth2Callback"
clientVersion = "2.14.0"
// apiKey is the app's public API-gateway key (not the user's secret).
apiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF"
// basicAuth is base64("oneapp:oneapp") — the OAuth client's basic credentials.
basicAuth = "basic b25lYXBwOm9uZWFwcA=="
userAgent = "okhttp/4.10.0"
)
// 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/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
// never fires with a token that expires mid-flight.
const tokenExpiryMargin = 30 * time.Second
func init() {
plugins.Register("toyota", func() plugins.Plugin { return &Plugin{} })
}
// tokenInfo holds the currently-held OAuth tokens and the derived account UUID.
type tokenInfo struct {
accessToken string
refreshToken string
uuid string
expiration time.Time
}
// Plugin is the Toyota Connected Europe connector.
type Plugin struct {
username string
password string
brand string // "T" (Toyota) or "L" (Lexus)
mu sync.Mutex // guards tok and the login flow
tok *tokenInfo
// authClient does NOT follow redirects — the authorize step must see the
// raw 302 to read the code out of the Location header.
authClient *http.Client
// dataClient follows redirects for ordinary data requests.
dataClient *http.Client
}
// Descriptor returns the plugin's static metadata for the admin panel.
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "toyota",
Provider: "Toyota Connected Europe",
Version: "0.2.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryVehicles,
AuthType: plugins.AuthOAuth2,
Capabilities: []plugins.Capability{
{ID: "vehicles", Method: "GET", Endpoint: epVehicleGUID, Description: "List vehicles registered to the account."},
{ID: "telemetry", Method: "GET", Endpoint: epTelemetry, Description: "Odometer, fuel level and range for a VIN."},
{ID: "location", Method: "GET", Endpoint: epLocation, Description: "Last known parked location for a VIN."},
{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."},
},
ConfigFields: []plugins.ConfigField{
// Credentials are intentionally NOT required at the global (panel) layer:
// this connector runs under each user's own MyToyota account, supplied in
// the Web App's per-user integration settings. A superadmin/org admin may
// still set a shared (e.g. fleet) account here that users inherit. See the
// cascade in internal/api/integrations.go.
{Key: "username", Label: "MyToyota email", Type: "text",
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
{Key: "password", Label: "MyToyota password", Type: "password", Secret: true,
Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
{Key: "brand", Label: "Brand", Type: "select", Default: "T",
Help: "Vehicle brand tied to the account.",
Options: []plugins.SelectOption{
{Value: "T", Label: "Toyota"},
{Value: "L", Label: "Lexus"},
}},
},
}
}
// Init applies resolved config and builds the HTTP clients. It performs no
// network I/O; login happens lazily on the first request or health check.
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.username = strings.TrimSpace(config["username"])
p.password = config["password"]
p.brand = strings.TrimSpace(config["brand"])
if p.brand == "" {
p.brand = "T"
}
// Config change invalidates any cached token.
p.tok = nil
p.authClient = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
p.dataClient = &http.Client{Timeout: 30 * time.Second}
return nil
}
// HealthCheck logs in (if needed) and lists the account's vehicles.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
body, _, err := p.apiRequest(ctx, http.MethodGet, epVehicleGUID, "", nil)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())}
}
detail := "authenticated; vehicles reachable"
if n, ok := countVehicles(body); ok {
detail = fmt.Sprintf("authenticated; %d vehicle(s) on account", n)
}
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
}
// Invoke runs a named read-only capability. Every capability except "vehicles"
// requires a "vin" in params. The upstream JSON is returned verbatim.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
var pp struct {
VIN string `json:"vin"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &pp); err != nil {
return nil, fmt.Errorf("toyota: invalid params: %w", err)
}
}
pp.VIN = strings.TrimSpace(pp.VIN)
var endpoint string
needVIN := true
switch action {
case "vehicles":
endpoint, needVIN = epVehicleGUID, false
case "telemetry":
endpoint = epTelemetry
case "location":
endpoint = epLocation
case "health":
endpoint = epHealthStatus
case "status":
endpoint = epRemoteStatus
case "electric":
endpoint = epElectricStatus
case "climate":
endpoint = epClimateStatus
case "climate-settings":
endpoint = epClimateSettings
case "notifications":
endpoint = epNotifications
case "service-history":
endpoint = epServiceHistory
default:
return nil, fmt.Errorf("toyota: unknown action %q", action)
}
if needVIN && pp.VIN == "" {
return nil, fmt.Errorf("toyota: action %q requires a vin", action)
}
body, _, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil)
if err != nil {
return nil, err
}
return json.RawMessage(body), nil
}
// Shutdown releases pooled connections.
func (p *Plugin) Shutdown(context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.dataClient != nil {
p.dataClient.CloseIdleConnections()
}
if p.authClient != nil {
p.authClient.CloseIdleConnections()
}
return nil
}
// ---- token management --------------------------------------------------------
// ensureToken guarantees a non-expired access token, refreshing or performing a
// full authentication as needed. Serialized by p.mu so concurrent requests
// trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.tok != nil && time.Now().UTC().Before(p.tok.expiration) {
return *p.tok, nil
}
if p.tok != nil && p.tok.refreshToken != "" {
if err := p.refreshTokens(ctx); err == nil {
return *p.tok, nil
}
// fall through to a full re-authentication
}
if err := p.authenticate(ctx); err != nil {
return tokenInfo{}, err
}
return *p.tok, nil
}
// authenticate runs the full three-legged login. Caller holds p.mu.
func (p *Plugin) authenticate(ctx context.Context) error {
if p.username == "" || p.password == "" {
return errors.New("toyota: username and password are required")
}
tokenID, err := p.performAuthentication(ctx)
if err != nil {
return err
}
code, err := p.performAuthorization(ctx, tokenID)
if err != nil {
return err
}
return p.retrieveTokens(ctx, code)
}
// performAuthentication drives the ForgeRock callback loop and returns the
// tokenId. The response object is echoed back in full each round (preserving
// authId and any fields we don't model), with only the input values filled in.
func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
var data map[string]any
for i := 0; i < 10; i++ {
if cbs, ok := data["callbacks"].([]any); ok {
for _, c := range cbs {
cb, ok := c.(map[string]any)
if !ok {
continue
}
switch typ, _ := cb["type"].(string); typ {
case "NameCallback":
if output0(cb) == "User Name" {
setInput0(cb, p.username)
}
case "PasswordCallback":
setInput0(cb, p.password)
case "TextOutputCallback":
if output0(cb) == "User Not Found" {
return "", errors.New("toyota: authentication failed — user not found")
}
}
}
}
payload := []byte("{}")
if data != nil {
b, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("toyota: encode auth payload: %w", err)
}
payload = b
}
resp, err := p.postRaw(ctx, authenticateURL, "application/json", payload, false)
if err != nil {
return "", err
}
body, status := resp.body, resp.status
if status != http.StatusOK {
return "", fmt.Errorf("toyota: authentication failed (HTTP %d): %s", status, shorten(string(body)))
}
data = map[string]any{}
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("toyota: decode auth response: %w", err)
}
if tid, ok := data["tokenId"].(string); ok && tid != "" {
return tid, nil
}
}
return "", errors.New("toyota: authentication failed — no tokenId after 10 rounds")
}
// performAuthorization exchanges the tokenId (as an SSO cookie) for an
// authorization code carried in the 302 Location header.
func (p *Plugin) performAuthorization(ctx context.Context, tokenID string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
if err != nil {
return "", err
}
req.Header.Set("cookie", "iPlanetDirectoryPro="+tokenID)
resp, err := p.authClient.Do(req)
if err != nil {
return "", fmt.Errorf("toyota: authorize request: %w", err)
}
defer drain(resp)
if resp.StatusCode != http.StatusFound {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("toyota: authorization failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
}
code := extractCode(resp.Header.Get("location"))
if code == "" {
return "", errors.New("toyota: authorization redirect carried no code")
}
return code, nil
}
// retrieveTokens exchanges the authorization code for the token set and stores
// it. Caller holds p.mu.
func (p *Plugin) retrieveTokens(ctx context.Context, code string) error {
form := url.Values{}
form.Set("client_id", "oneapp")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", "plain")
return p.tokenRequest(ctx, form)
}
// refreshTokens uses the stored refresh token to obtain a fresh access token.
// Caller holds p.mu.
func (p *Plugin) refreshTokens(ctx context.Context) error {
if p.tok == nil || p.tok.refreshToken == "" {
return errors.New("toyota: no refresh token")
}
form := url.Values{}
form.Set("client_id", "oneapp")
form.Set("redirect_uri", redirectURI)
form.Set("grant_type", "refresh_token")
form.Set("code_verifier", "plain")
form.Set("refresh_token", p.tok.refreshToken)
return p.tokenRequest(ctx, form)
}
// tokenRequest POSTs a form to the access-token endpoint and stores the result.
func (p *Plugin) tokenRequest(ctx context.Context, form url.Values) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, accessTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("authorization", basicAuth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.authClient.Do(req)
if err != nil {
return fmt.Errorf("toyota: token request: %w", err)
}
defer drain(resp)
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("toyota: token request failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
}
return p.storeTokens(body)
}
// storeTokens parses a token response, decodes the account UUID from the id
// token, and updates p.tok. Caller holds p.mu.
func (p *Plugin) storeTokens(body []byte) error {
var td struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &td); err != nil {
return fmt.Errorf("toyota: decode token response: %w", err)
}
var missing []string
if td.AccessToken == "" {
missing = append(missing, "access_token")
}
if td.IDToken == "" {
missing = append(missing, "id_token")
}
if td.RefreshToken == "" {
missing = append(missing, "refresh_token")
}
if td.ExpiresIn == 0 {
missing = append(missing, "expires_in")
}
if len(missing) > 0 {
return fmt.Errorf("toyota: token response missing fields: %s", strings.Join(missing, ", "))
}
uuid, err := jwtUUID(td.IDToken)
if err != nil {
return fmt.Errorf("toyota: %w", err)
}
lifetime := time.Duration(td.ExpiresIn)*time.Second - tokenExpiryMargin
if lifetime < 0 {
lifetime = 0
}
p.tok = &tokenInfo{
accessToken: td.AccessToken,
refreshToken: td.RefreshToken,
uuid: uuid,
expiration: time.Now().UTC().Add(lifetime),
}
return nil
}
// ---- data requests -----------------------------------------------------------
// apiRequest performs an authenticated request against the data API, retrying
// transient failures (429 and 5xx) with exponential backoff. It returns the
// response body and status; a non-2xx final status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, method, endpoint, vin string, params url.Values) ([]byte, int, error) {
tok, err := p.ensureToken(ctx)
if err != nil {
return nil, 0, err
}
target := apiBaseURL + endpoint
if len(params) > 0 {
target += "?" + params.Encode()
}
backoffs := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastBody []byte
var lastStatus int
for attempt := 0; attempt <= len(backoffs); attempt++ {
req, err := http.NewRequestWithContext(ctx, method, target, nil)
if err != nil {
return nil, 0, err
}
p.applyHeaders(req.Header, vin, tok)
resp, err := p.dataClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("toyota: request %s: %w", endpoint, err)
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
drain(resp)
lastBody, lastStatus = body, resp.StatusCode
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted {
return body, resp.StatusCode, nil
}
transient := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError
if !transient || attempt == len(backoffs) {
break
}
select {
case <-ctx.Done():
return nil, lastStatus, ctx.Err()
case <-time.After(backoffs[attempt]):
}
}
return lastBody, lastStatus, fmt.Errorf("toyota: request %s failed (HTTP %d): %s", endpoint, lastStatus, shorten(string(lastBody)))
}
// applyHeaders writes the full set of headers Toyota's gateway expects. Keys are
// set directly on the map (lowercase, uncanonicalized) to match the app exactly
// on HTTP/1.1; HTTP/2 lowercases all header names regardless.
func (p *Plugin) applyHeaders(h http.Header, vin string, tok tokenInfo) {
set := func(k, v string) { h[k] = []string{v} }
set("x-api-key", apiKey)
set("API_KEY", apiKey)
set("x-guid", tok.uuid)
set("guid", tok.uuid)
set("x-client-ref", hmacSHA256(clientVersion, tok.uuid))
set("x-correlationid", genUUID())
set("x-appversion", clientVersion)
set("x-channel", "ONEAPP")
set("x-brand", p.brand)
set("x-region", "EU")
set("authorization", "Bearer "+tok.accessToken)
set("user-agent", userAgent)
if p.brand == "L" {
set("x-appbrand", "L")
set("brand", "L")
}
if vin != "" {
set("vin", vin)
}
}
// ---- small helpers -----------------------------------------------------------
// rawResponse is a fully-read response used by the auth flow.
type rawResponse struct {
status int
body []byte
}
// postRaw POSTs a body and reads the whole response. When followRedirects is
// false the no-redirect authClient is used.
func (p *Plugin) postRaw(ctx context.Context, u, contentType string, body []byte, followRedirects bool) (rawResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return rawResponse{}, err
}
req.Header.Set("Content-Type", contentType)
client := p.authClient
if followRedirects {
client = p.dataClient
}
resp, err := client.Do(req)
if err != nil {
return rawResponse{}, fmt.Errorf("toyota: POST %s: %w", u, err)
}
defer drain(resp)
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return rawResponse{status: resp.StatusCode, body: b}, nil
}
// output0 returns callback.output[0].value.
func output0(cb map[string]any) string {
out, _ := cb["output"].([]any)
if len(out) == 0 {
return ""
}
m, _ := out[0].(map[string]any)
v, _ := m["value"].(string)
return v
}
// setInput0 sets callback.input[0].value in place.
func setInput0(cb map[string]any, val string) {
in, _ := cb["input"].([]any)
if len(in) == 0 {
return
}
if m, ok := in[0].(map[string]any); ok {
m["value"] = val
}
}
// extractCode pulls the ?code= parameter out of a redirect Location, tolerating
// the app's custom URI scheme (com.toyota.oneapp:/oauth2Callback?code=…).
func extractCode(location string) string {
i := strings.IndexByte(location, '?')
if i < 0 {
return ""
}
vals, err := url.ParseQuery(location[i+1:])
if err != nil {
return ""
}
return vals.Get("code")
}
// jwtUUID base64url-decodes a JWT's payload (without signature verification, as
// pytoyoda does) and returns its "uuid" claim.
func jwtUUID(idToken string) (string, error) {
parts := strings.Split(idToken, ".")
if len(parts) < 2 {
return "", errors.New("malformed id token")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// tolerate padded encodings
if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return "", fmt.Errorf("decode id token: %w", err)
}
}
var claims struct {
UUID string `json:"uuid"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("decode id token claims: %w", err)
}
if claims.UUID == "" {
return "", errors.New("id token has no uuid claim")
}
return claims.UUID, nil
}
// hmacSHA256 returns the lowercase hex HMAC-SHA256 of message under key.
func hmacSHA256(key, message string) string {
m := hmac.New(sha256.New, []byte(key))
m.Write([]byte(message))
return hex.EncodeToString(m.Sum(nil))
}
// genUUID returns a random RFC-4122 v4 UUID string.
func genUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand should never fail; fall back to a fixed correlation id.
return "00000000-0000-4000-8000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// countVehicles best-effort extracts the vehicle count from a /vehicle/guid
// response. The payload is an array of vehicle objects under "payload".
func countVehicles(body []byte) (int, bool) {
var env struct {
Payload []json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(body, &env); err != nil || env.Payload == nil {
return 0, false
}
return len(env.Payload), true
}
// drain closes a response body after discarding any remainder so the connection
// can be reused.
func drain(resp *http.Response) {
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
}
}
// shorten trims long/multiline upstream messages for health details and errors.
func shorten(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 200 {
return s[:200] + "…"
}
return s
}