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,261 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in new issue
Block a user