Add BYD vehicle telemetry exporter
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]>
This commit is contained in:
thistineandClaude Opus 5 committed 2026-09-05 22:03:16 +07:00
commit 52e1aaa0c0
30 files changed
+3501

No files matched your search

+267
View File
@@ -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
}