Files
byd-exporter/internal/byd/bydtest/server.go
T
thistineandClaude Opus 5 52e1aaa0c0
Test / test (push) Failing after 25m47s
Add BYD vehicle telemetry exporter
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]>
2026-09-05 22:03:16 +07:00

262 lines
7.4 KiB
Go

// Package bydtest provides a fake BYD cloud for tests.
//
// It speaks the real protocol — unwraps the bangcle envelope, checks the
// checkcode, decrypts encryData and encrypts its replies the same way — so
// tests against it exercise the whole client stack rather than a mocked-out
// transport.
//
// The server side is written independently of package byd (its own hashing and
// AES helpers, its own hard-coded endpoint paths) on purpose: if both sides
// shared a buggy helper, the bug would cancel out and the test would pass.
package bydtest
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.tinetunnel.com/thistine/byd-exporter/internal/bangcle"
)
// Endpoint paths, spelled out here rather than imported, so that a typo in the
// client's path constants shows up as an unexpected-endpoint failure.
const (
LoginPath = "/app/account/login"
VehiclesPath = "/app/account/getAllListByUserId"
RealtimeTriggerPath = "/vehicleInfo/vehicle/vehicleRealTimeRequest"
RealtimeResultPath = "/vehicleInfo/vehicle/vehicleRealTimeResult"
)
// Credentials the fake accepts. Tests configure their client to match.
const (
Password = "hunter2"
UserID = 42
SignToken = "sign-token-value"
EncryToken = "encry-token-value"
VIN = "LC0TEST0000000001"
)
// Server is a fake BYD cloud. The exported fields tune its behaviour; the
// counters record what the client did.
type Server struct {
*httptest.Server
t *testing.T
// LoginCode is the response code returned by the login endpoint ("0" is success).
LoginCode string
// ReadyAfterPolls is how many polls of the result endpoint it takes before
// a real reading appears. 1 means the first poll answers.
ReadyAfterPolls int
// Vehicles is the vehicle-list payload. Nil means one default car.
Vehicles []map[string]any
// Realtime is the reading returned once ready. Nil means a default one.
Realtime map[string]any
// Logins and Polls count requests to those endpoints.
Logins int
Polls int
}
// New starts a fake cloud with sensible defaults and registers its shutdown.
func New(t *testing.T) *Server {
t.Helper()
s := &Server{t: t, LoginCode: "0", ReadyAfterPolls: 1}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// DefaultVehicles and DefaultRealtime are what the fake returns unless a test
// overrides them.
func DefaultVehicles() []map[string]any {
return []map[string]any{{
"vin": VIN,
"modelName": "SEALION 7",
"brandName": "BYD",
"autoAlias": "Daily",
"autoPlate": "AB-1234",
"energyType": 0,
}}
}
func DefaultRealtime() map[string]any {
return map[string]any{
"requestSerial": "serial-1",
"onlineState": 1,
"elecPercent": 82,
"enduranceMileage": 391,
"totalMileage": 14203,
"leftFrontTirepressure": 240,
"vin": VIN,
}
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.t.Helper()
outer, raw, ok := s.unwrap(w, r)
if !ok {
return
}
s.verifyCheckcode(raw, outer)
switch r.URL.Path {
case LoginPath:
s.Logins++
// The login response is keyed off the password, since there is no
// session token yet.
s.reply(w, loginKey(Password), s.LoginCode, map[string]any{
"token": map[string]any{
"userId": UserID, // a number, as the cloud sends it
"signToken": SignToken,
"encryToken": EncryToken,
},
})
case VehiclesPath:
vehicles := s.Vehicles
if vehicles == nil {
vehicles = DefaultVehicles()
}
s.reply(w, contentKey(), "0", vehicles)
case RealtimeTriggerPath:
// The trigger only acknowledges; the car has to wake up first.
s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"})
case RealtimeResultPath:
s.Polls++
if s.Polls < s.ReadyAfterPolls {
s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"})
return
}
reading := s.Realtime
if reading == nil {
reading = DefaultRealtime()
}
s.reply(w, contentKey(), "0", reading)
default:
s.t.Errorf("client called an unexpected endpoint: %s", r.URL.Path)
http.Error(w, "not found", http.StatusNotFound)
}
}
// unwrap decodes the bangcle envelope and returns the outer payload both parsed
// and raw (the raw bytes are needed to verify the checkcode).
func (s *Server) unwrap(w http.ResponseWriter, r *http.Request) (map[string]any, []byte, bool) {
s.t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
s.t.Errorf("reading request body: %v", err)
return nil, nil, false
}
var wrapper struct {
Request string `json:"request"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
s.t.Errorf("request body is not JSON: %s", body)
return nil, nil, false
}
raw, err := bangcle.Decode(wrapper.Request)
if err != nil {
s.t.Errorf("client sent an undecodable envelope: %v", err)
return nil, nil, false
}
var outer map[string]any
if err := json.Unmarshal(raw, &outer); err != nil {
s.t.Errorf("outer payload is not JSON: %s", raw)
return nil, nil, false
}
return outer, raw, true
}
// verifyCheckcode recomputes the checkcode the way the cloud does. It also
// asserts that checkcode is the final field, because the hash covers exactly
// the object that precedes it — get the field order wrong and this fails.
func (s *Server) verifyCheckcode(raw []byte, outer map[string]any) {
s.t.Helper()
got, _ := outer["checkcode"].(string)
if got == "" {
s.t.Error("request carries no checkcode")
return
}
body := string(raw)
idx := strings.LastIndex(body, `,"checkcode":`)
if idx < 0 || !strings.HasSuffix(body, `"}`) {
s.t.Errorf("checkcode is not the last field: %s", body)
return
}
sum := md5.Sum([]byte(body[:idx] + "}"))
h := hex.EncodeToString(sum[:])
if want := h[24:32] + h[8:16] + h[16:24] + h[0:8]; got != want {
s.t.Errorf("checkcode = %s, want %s", got, want)
}
}
// reply encrypts respondData with key and wraps the response in an envelope.
func (s *Server) reply(w http.ResponseWriter, key, code string, respondData any) {
s.t.Helper()
body := map[string]any{"code": code, "message": "ok"}
if code == "0" {
payload, err := json.Marshal(respondData)
if err != nil {
s.t.Fatal(err)
}
body["respondData"] = encryptHex(s.t, payload, key)
}
outer, err := json.Marshal(body)
if err != nil {
s.t.Fatal(err)
}
resp, err := json.Marshal(map[string]string{"response": bangcle.Encode(outer)})
if err != nil {
s.t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
// The server-side key derivations, written out rather than imported from the
// client so the two implementations can disagree.
func md5Upper(s string) string {
sum := md5.Sum([]byte(s))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func loginKey(password string) string { return md5Upper(md5Upper(password)) }
func contentKey() string { return md5Upper(EncryToken) }
func encryptHex(t *testing.T, plaintext []byte, keyHex string) string {
t.Helper()
key, err := hex.DecodeString(keyHex)
if err != nil {
t.Fatalf("bad key %q: %v", keyHex, err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
pad := 16 - len(plaintext)%16 // PKCS#7: always 1..16 bytes
data := append([]byte{}, plaintext...)
for i := 0; i < pad; i++ {
data = append(data, byte(pad))
}
out := make([]byte, len(data))
cipher.NewCBCEncrypter(block, make([]byte, 16)).CryptBlocks(out, data)
return strings.ToUpper(hex.EncodeToString(out))
}