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,267 @@
|
||||
// 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<base64>"}
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user