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]>
272 lines
7.8 KiB
Go
272 lines
7.8 KiB
Go
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
|
|
}
|