// Package byd is a read-only client for the BYD cloud API — the same endpoints // the BYD mobile app calls. Ported from pybyd 0.0.75. // // Every request is wrapped twice: // // outer JSON -> bangcle white-box AES -> {"request": "F"} // encryData -> AES-128-CBC (hex) with a key derived from the session token // // The outer envelope carries the device identity and a signature; the inner // encryData carries the actual request fields. // // MQTT push is deliberately not implemented. pybyd waits for a push and falls // back to HTTP polling on timeout; this client only polls, which costs a few // extra requests per reading and is plenty for scraping. package byd import ( "bytes" "context" "encoding/hex" "encoding/json" "fmt" "io" "math/rand" "net/http" "net/http/cookiejar" "strings" "time" "git.tinetunnel.com/thistine/byd-exporter/internal/bangcle" "git.tinetunnel.com/thistine/byd-exporter/internal/jsonv" ) const userAgent = "okhttp/4.12.0" // sessionExpiredCodes are the API codes that mean "log in again". var sessionExpiredCodes = map[string]bool{"1002": true, "1005": true, "1010": true} // Config holds everything needed to impersonate one app install. type Config struct { Username, Password string BaseURL string CountryCode string Language string TimeZone string AppName string AppVersion string AppInnerVersion string SoftType string TboxVersion string IsAuto string SessionTTL time.Duration PollAttempts int PollInterval time.Duration Device Device } type session struct { userID, signToken, encryToken string created time.Time } // The two AES keys for a logged-in session are derived from the tokens the // login response carries, not from the password. func (s *session) contentKey() string { return md5Hex(s.encryToken) } func (s *session) signKey() string { return md5Hex(s.signToken) } // Client is not safe for concurrent use: one scrape at a time. type Client struct { cfg Config http *http.Client sess *session // Overridden in tests to reproduce a payload byte for byte. now func() time.Time nonce func() string } // New returns a client with a cookie jar; BYD sets session cookies the // subsequent requests need. func New(cfg Config) (*Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } return &Client{ cfg: cfg, http: &http.Client{Jar: jar, Timeout: 30 * time.Second}, now: time.Now, nonce: randomHex16, }, nil } // APIError is a non-zero response code from an endpoint. Callers branch on Code // to tell "session expired" from "car asleep" from a real failure. type APIError struct { Endpoint, Code, Message string } func (e *APIError) Error() string { return fmt.Sprintf("%s failed: code=%s message=%s", e.Endpoint, e.Code, e.Message) } func randomHex16() string { b := make([]byte, 16) // A request nonce, not a key: math/rand is fine and avoids an error path. rand.Read(b) return strings.ToUpper(hex.EncodeToString(b)) } // postSecure sends one bangcle-wrapped request and returns the decoded outer // response. The respondData inside is still encrypted at this point. func (c *Client) postSecure(ctx context.Context, endpoint string, outer jmap) (map[string]any, error) { payload, err := marshalCompact(outer) if err != nil { return nil, err } body, err := json.Marshal(map[string]string{"request": bangcle.Encode(payload)}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.BaseURL+endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("accept-encoding", "identity") req.Header.Set("content-type", "application/json; charset=UTF-8") req.Header.Set("user-agent", userAgent) resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("%s: %w", endpoint, err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return nil, fmt.Errorf("%s: %w", endpoint, err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: HTTP %d: %.200s", endpoint, resp.StatusCode, raw) } var envelope struct { Response string `json:"response"` } if err := json.Unmarshal(raw, &envelope); err != nil { return nil, fmt.Errorf("%s: invalid JSON: %.200s", endpoint, raw) } if strings.TrimSpace(envelope.Response) == "" { return nil, fmt.Errorf("%s: empty response payload", endpoint) } decoded, err := bangcle.Decode(envelope.Response) if err != nil { return nil, fmt.Errorf("%s: %w", endpoint, err) } text := strings.TrimSpace(string(decoded)) // Some responses come back with a stray F prefix on the decoded JSON. if strings.HasPrefix(text, "F{") || strings.HasPrefix(text, "F[") { text = text[1:] } var out map[string]any if err := json.Unmarshal([]byte(text), &out); err != nil { return nil, fmt.Errorf("%s: decoded payload is not JSON: %.64s", endpoint, text) } return out, nil } // commonOuter adds the device identity fields every envelope carries. func (c *Client) commonOuter(outer *jmap) { d := c.cfg.Device outer.set("ostype", d.OSType) outer.set("imei", d.IMEI) outer.set("mac", d.MAC) outer.set("model", d.Model) outer.set("sdk", d.SDK) outer.set("mod", d.Mod) } // innerBase builds the fields every post-login endpoint sends. func (c *Client) innerBase(vin, requestSerial string) jmap { inner := jmap{} inner.set("deviceType", c.cfg.Device.DeviceType) inner.set("imeiMD5", c.cfg.Device.IMEIMD5) inner.set("networkType", c.cfg.Device.NetworkType) inner.set("random", c.nonce()) inner.set("timeStamp", fmt.Sprint(c.now().UnixMilli())) inner.set("version", c.cfg.AppInnerVersion) if vin != "" { inner.set("vin", vin) } if requestSerial != "" { inner.set("requestSerial", requestSerial) } return inner } // postToken signs, encrypts and sends a post-login request, returning the // decrypted respondData as raw JSON. It may be an object or an array. func (c *Client) postToken(ctx context.Context, endpoint string, inner jmap) (json.RawMessage, error) { cfg := c.cfg d := cfg.Device s := c.sess if s == nil { return nil, fmt.Errorf("%s: not logged in", endpoint) } reqTimestamp := fmt.Sprint(c.now().UnixMilli()) contentKey := s.contentKey() innerJSON, err := marshalCompact(inner) if err != nil { return nil, err } encryData, err := aesEncryptHex(string(innerJSON), contentKey) if err != nil { return nil, err } signFields := inner.clone() signFields.set("countryCode", cfg.CountryCode) signFields.set("identifier", s.userID) signFields.set("imeiMD5", d.IMEIMD5) signFields.set("language", cfg.Language) signFields.set("reqTimestamp", reqTimestamp) outer := jmap{} outer.set("countryCode", cfg.CountryCode) outer.set("encryData", encryData) outer.set("identifier", s.userID) outer.set("imeiMD5", d.IMEIMD5) outer.set("language", cfg.Language) outer.set("reqTimestamp", reqTimestamp) outer.set("sign", sha1Mixed(buildSignString(signFields, s.signKey()))) 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) resp, err := c.postSecure(ctx, endpoint, outer) if err != nil { return nil, err } if rc := jsonv.Str(resp["code"]); rc != "0" { return nil, &APIError{endpoint, rc, jsonv.Str(resp["message"])} } respondData := jsonv.Str(resp["respondData"]) if respondData == "" { return json.RawMessage("{}"), nil } plaintext, err := aesDecryptUTF8(respondData, contentKey) if err != nil { return nil, fmt.Errorf("%s: %w", endpoint, err) } if strings.TrimSpace(plaintext) == "" { return json.RawMessage("{}"), nil } if !json.Valid([]byte(plaintext)) { return nil, fmt.Errorf("%s: respondData is not JSON: %.128s", endpoint, plaintext) } return json.RawMessage(plaintext), nil }