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