Add BYD vehicle telemetry exporter
Test / test (push) Failing after 25m47s

Go port of the read path of pybyd 0.0.75 (the client behind
jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the
signing/checkcode layer, login, vehicle list, and realtime readings.

Scraping runs on a cron schedule rather than per Prometheus request
because each reading wakes the car's T-Box and drains the traction
battery; /metrics serves the last result.

Every numeric field of the realtime payload becomes a gauge, so the
exporter does not need a hand-maintained field list that goes stale
when BYD changes the payload.

Tests pin the wire format against pybyd byte-for-byte and drive the
client end to end against a fake cloud that speaks the real protocol.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
thistineandClaude Opus 5 committed 2026-09-05 22:03:16 +07:00
commit 52e1aaa0c0
30 files changed
+3501

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
package byd
// The three endpoints a scrape needs: log in, list the cars, read one car.
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
)
const (
loginEndpoint = "/app/account/login"
vehiclesEndpoint = "/app/account/getAllListByUserId"
realtimeTrigger = "/vehicleInfo/vehicle/vehicleRealTimeRequest"
realtimeResult = "/vehicleInfo/vehicle/vehicleRealTimeResult"
)
// Vehicle is the subset of the vehicle-list entry used for metric labels.
// energyType matters beyond labelling: sending EV for a hybrid makes the cloud
// zero out the combustion-side fields.
type Vehicle struct {
VIN string `json:"vin"`
ModelName string `json:"modelName"`
BrandName string `json:"brandName"`
AutoAlias string `json:"autoAlias"`
AutoPlate string `json:"autoPlate"`
EnergyType any `json:"energyType"`
}
// Name is the friendliest label available for this car.
func (v Vehicle) Name() string {
for _, s := range []string{v.AutoAlias, v.AutoPlate, v.ModelName} {
if s != "" {
return s
}
}
return v.VIN
}
// EnsureSession logs in if there is no session or it has aged out.
func (c *Client) EnsureSession(ctx context.Context) error {
if c.sess != nil && c.now().Sub(c.sess.created) < c.cfg.SessionTTL {
return nil
}
return c.Login(ctx)
}
// Login authenticates with username and password and stores the session tokens.
func (c *Client) Login(ctx context.Context) error {
loginStart := c.now()
outer, err := c.loginRequest()
if err != nil {
return err
}
resp, err := c.postSecure(ctx, loginEndpoint, outer)
if err != nil {
return err
}
if code := jsonv.Str(resp["code"]); code != "0" {
return &APIError{loginEndpoint, code, jsonv.Str(resp["message"])}
}
respondData := jsonv.Str(resp["respondData"])
if respondData == "" {
return fmt.Errorf("%s: response missing respondData", loginEndpoint)
}
// The login response is encrypted with a key derived from the password,
// since there is no session token yet.
plaintext, err := aesDecryptUTF8(respondData, pwdLoginKey(c.cfg.Password))
if err != nil {
return fmt.Errorf("%s: %w", loginEndpoint, err)
}
var body struct {
Token struct {
UserID any `json:"userId"`
SignToken string `json:"signToken"`
EncryToken string `json:"encryToken"`
} `json:"token"`
}
if err := json.Unmarshal([]byte(plaintext), &body); err != nil {
return fmt.Errorf("%s: %w", loginEndpoint, err)
}
userID := jsonv.Str(body.Token.UserID)
if userID == "" || body.Token.SignToken == "" || body.Token.EncryToken == "" {
return fmt.Errorf("%s: response missing token fields", loginEndpoint)
}
c.sess = &session{
userID: userID,
signToken: body.Token.SignToken,
encryToken: body.Token.EncryToken,
created: loginStart,
}
return nil
}
// loginRequest builds the outer payload for the login endpoint. Split out from
// Login so a test can compare it against the reference client's output.
func (c *Client) loginRequest() (jmap, error) {
cfg := c.cfg
d := cfg.Device
reqTimestamp := fmt.Sprint(c.now().UnixMilli())
inner := jmap{}
inner.set("agreeStatus", "0")
inner.set("agreementType", "[1,2]")
inner.set("appInnerVersion", cfg.AppInnerVersion)
inner.set("appVersion", cfg.AppVersion)
inner.set("deviceName", d.MobileBrand+d.MobileModel)
inner.set("deviceType", d.DeviceType)
inner.set("imeiMD5", d.IMEIMD5)
inner.set("isAuto", cfg.IsAuto)
inner.set("mobileBrand", d.MobileBrand)
inner.set("mobileModel", d.MobileModel)
inner.set("networkType", d.NetworkType)
inner.set("osType", d.AndroidVer)
inner.set("osVersion", d.OSVersion)
inner.set("random", c.nonce())
inner.set("softType", cfg.SoftType)
inner.set("timeStamp", reqTimestamp)
inner.set("timeZone", cfg.TimeZone)
innerJSON, err := marshalCompact(inner)
if err != nil {
return nil, err
}
encryData, err := aesEncryptHex(string(innerJSON), pwdLoginKey(cfg.Password))
if err != nil {
return nil, err
}
signFields := inner.clone()
signFields.set("appName", cfg.AppName)
signFields.set("countryCode", cfg.CountryCode)
signFields.set("functionType", "pwdLogin")
signFields.set("identifier", cfg.Username)
signFields.set("identifierType", "0")
signFields.set("language", cfg.Language)
signFields.set("reqTimestamp", reqTimestamp)
outer := jmap{}
outer.set("appName", cfg.AppName)
outer.set("countryCode", cfg.CountryCode)
outer.set("encryData", encryData)
outer.set("functionType", "pwdLogin")
outer.set("identifier", cfg.Username)
outer.set("identifierType", "0")
outer.set("imeiMD5", d.IMEIMD5)
outer.set("isAuto", cfg.IsAuto)
outer.set("language", cfg.Language)
outer.set("reqTimestamp", reqTimestamp)
outer.set("sign", sha1Mixed(buildSignString(signFields, md5Hex(cfg.Password))))
outer.set("signKey", cfg.Password)
c.commonOuter(&outer)
outer.set("serviceTime", fmt.Sprint(c.now().UnixMilli()))
code, err := checkcode(outer)
if err != nil {
return nil, err
}
outer.set("checkcode", code)
return outer, nil
}
// Vehicles lists the cars on the account.
func (c *Client) Vehicles(ctx context.Context) ([]Vehicle, error) {
raw, err := c.postToken(ctx, vehiclesEndpoint, c.innerBase("", ""))
if err != nil {
return nil, err
}
var out []Vehicle
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%s: %w", vehiclesEndpoint, err)
}
return out, nil
}
// Realtime triggers a fresh reading and polls until the car answers.
//
// The trigger endpoint normally returns only a requestSerial — the car has to
// wake up and report before the result endpoint has anything to give. The app
// waits for an MQTT push; this polls the result endpoint instead.
//
// A car that never answers is not an error: the last payload seen is returned,
// so a stale-but-real reading beats no metrics at all.
func (c *Client) Realtime(ctx context.Context, vin, energyType string) (map[string]any, error) {
latest, serial, err := c.realtimeCall(ctx, realtimeTrigger, vin, energyType, "")
if err != nil {
return nil, err
}
if RealtimeReady(latest) || serial == "" {
return latest, nil
}
for attempt := 0; attempt < c.cfg.PollAttempts; attempt++ {
select {
case <-ctx.Done():
return latest, ctx.Err()
case <-time.After(c.cfg.PollInterval):
}
info, next, err := c.realtimeCall(ctx, realtimeResult, vin, energyType, serial)
if err != nil {
// Session expiry will not fix itself by polling again; anything
// else (car asleep, backend busy) might.
var apiErr *APIError
if errors.As(err, &apiErr) && sessionExpiredCodes[apiErr.Code] {
return latest, err
}
continue
}
serial = next
if len(info) > 0 {
latest = info
}
if RealtimeReady(info) {
break
}
}
return latest, nil
}
func (c *Client) realtimeCall(ctx context.Context, endpoint, vin, energyType, serial string) (map[string]any, string, error) {
inner := c.innerBase(vin, serial)
inner.set("energyType", energyType)
inner.set("tboxVersion", c.cfg.TboxVersion)
raw, err := c.postToken(ctx, endpoint, inner)
if err != nil {
return nil, serial, err
}
var info map[string]any
if err := json.Unmarshal(raw, &info); err != nil {
return nil, serial, nil // not an object; nothing usable, but not fatal
}
if s := jsonv.Str(info["requestSerial"]); s != "" {
serial = s
}
return info, serial, nil
}
// RealtimeReady reports whether a payload carries an actual reading rather than
// just an acknowledgement of the trigger.
func RealtimeReady(info map[string]any) bool {
if len(info) == 0 {
return false
}
const offline = 2
if v, ok := jsonv.Num(info["onlineState"]); ok && v == offline {
return false
}
for _, k := range []string{
"leftFrontTirepressure", "rightFrontTirepressure",
"leftRearTirepressure", "rightRearTirepressure",
} {
if v, ok := jsonv.Num(info[k]); ok && v > 0 {
return true
}
}
if v, ok := jsonv.Num(info["time"]); ok && v > 0 {
return true
}
v, ok := jsonv.Num(info["enduranceMileage"])
return ok && v > 0
}