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]>
262 lines
7.4 KiB
Go
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))
|
|
}
|