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 }