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:
commit
52e1aaa0c0
30 files changed
+3501
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user