Files
byd-exporter/internal/byd/client_test.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

273 lines
9.0 KiB
Go

package byd
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest"
)
// testDevice is the fingerprint the pybyd reference payload was generated with.
var testDevice = Device{
OSType: "and", IMEI: "356271112345674", MAC: "02:11:22:33:44:55",
Model: "SM-S928B", SDK: "35", Mod: "samsung",
IMEIMD5: "deadbeefdeadbeefdeadbeefdeadbeef",
MobileBrand: "SAMSUNG", MobileModel: "SM-S928B",
DeviceType: "0", NetworkType: "wifi", AndroidVer: "15", OSVersion: "35",
}
func testConfig(baseURL string) Config {
return Config{
Username: "[email protected]", Password: bydtest.Password,
BaseURL: baseURL,
CountryCode: "NL", Language: "en", TimeZone: "UTC",
AppName: "pyBYD+0.0.75", AppVersion: "3.5.1", AppInnerVersion: "351",
SoftType: "0", TboxVersion: "3", IsAuto: "1",
SessionTTL: time.Hour, PollAttempts: 5, PollInterval: time.Millisecond,
Device: testDevice,
}
}
// newTestClient freezes the clock and the nonce so payloads are reproducible.
func newTestClient(t *testing.T, cfg Config) *Client {
t.Helper()
c, err := New(cfg)
if err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return time.UnixMilli(1700000000000) }
c.nonce = func() string { return strings.Repeat("0", 32) }
return c
}
// TestLoginRequestMatchesPybyd compares a complete login payload — field order,
// encryData, sign and checkcode — against pybyd's build_login_request run with
// the same frozen clock and nonce. This is what catches a renamed, missing or
// misordered field, which the primitive tests in crypto_test.go cannot see.
func TestLoginRequestMatchesPybyd(t *testing.T) {
const want = `{"appName":"pyBYD+0.0.75","countryCode":"NL","encryData":"2F01666A8BC0E6DBD65D2CB5EDF6AA1D9CD01D40F48A5EDE699779B21E399C5223841BD789B595E1BE40C5A0BCA07A26A0B649AB6DD3E63BC4F112D71E0EC4A904312DBF56DEBA50F407B678C1A1E8FD8AFC4F76520AD7D9EB03D9C430160C787C209DE5B38133CFBD9123E4F935E86A42EE732731F336B740BFDB206EEC7A26D756B1CF5ADF20F595532810A6019F5A717C21475DBDBA5F5415E0D4CA1AD26433761852FEE204DA496E2C775EF22C30B5EEBDCBEA164D9518F2345EC5DE5E70A6CC22B8D18B2B7BB9AEED652F2D123B2D96E5787C0EA39E20DBE529A2A4EB4EC51899D5A3CB2AEC8FF1EA1561CBA7D347C82CC075A4ECD92A48549F5492D4B4C0E487F3A45F8C7060880EF881254DBA578FAE4CBE8AA8777EFF7A26272E61867ABB33ECB752476BBB69A257CFA57C4C6E1269DA5EDFED322F13202ADC28B318C8868DFEDF3138E0A6655BB7B037480613975F5A437A698138DEA7DD26DB0B0F1445DE3D6B3C3B1B15D6DEAEFF9545EB9EE29F17A1C77BD4239ACDC00310571E1C338AE783106FDFAC488A0C6E0A4659","functionType":"pwdLogin","identifier":"[email protected]","identifierType":"0","imeiMD5":"deadbeefdeadbeefdeadbeefdeadbeef","isAuto":"1","language":"en","reqTimestamp":"1700000000000","sign":"97d29Abe36be116c9A3681703Ead98ad86c0D088","signKey":"hunter2","ostype":"and","imei":"356271112345674","mac":"02:11:22:33:44:55","model":"SM-S928B","sdk":"35","mod":"samsung","serviceTime":"1700000000000","checkcode":"8ac193b5be6e01d003b5c1da9e9036d8"}`
c := newTestClient(t, testConfig("http://example.invalid"))
outer, err := c.loginRequest()
if err != nil {
t.Fatal(err)
}
got, err := marshalCompact(outer)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Errorf("login payload differs from pybyd\n got %s\nwant %s", got, want)
}
}
func TestLoginAndVehicles(t *testing.T) {
srv := bydtest.New(t)
c := newTestClient(t, testConfig(srv.URL))
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatalf("login: %v", err)
}
// userId arrives as a JSON number; it has to survive as "42", not "4.2e+01".
if c.sess.userID != "42" {
t.Errorf("userID = %q, want 42", c.sess.userID)
}
vehicles, err := c.Vehicles(ctx)
if err != nil {
t.Fatalf("Vehicles: %v", err)
}
if len(vehicles) != 1 {
t.Fatalf("got %d vehicles, want 1", len(vehicles))
}
if got, want := vehicles[0].VIN, bydtest.VIN; got != want {
t.Errorf("VIN = %q, want %q", got, want)
}
if got, want := vehicles[0].Name(), "Daily"; got != want {
t.Errorf("Name = %q, want %q", got, want)
}
}
// TestRealtimePollsUntilReady drives the trigger-then-poll flow: the trigger
// only acknowledges, and the reading appears on a later poll.
func TestRealtimePollsUntilReady(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 3
c := newTestClient(t, testConfig(srv.URL))
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
data, err := c.Realtime(ctx, bydtest.VIN, "0")
if err != nil {
t.Fatalf("Realtime: %v", err)
}
if got := data["elecPercent"]; got != float64(82) {
t.Errorf("elecPercent = %v, want 82", got)
}
if srv.Polls != 3 {
t.Errorf("polled %d times, want 3 (stopping as soon as the car answers)", srv.Polls)
}
}
// TestRealtimeGivesUpOnSleepingCar covers the car that never answers: the
// trigger's payload comes back instead of an error, so the scrape still
// produces metrics for the rest of the fleet.
func TestRealtimeGivesUpOnSleepingCar(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 1000 // never within PollAttempts
cfg := testConfig(srv.URL)
cfg.PollAttempts = 3
c := newTestClient(t, cfg)
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
data, err := c.Realtime(ctx, bydtest.VIN, "0")
if err != nil {
t.Fatalf("Realtime errored on a sleeping car: %v", err)
}
if _, ok := data["requestSerial"]; !ok {
t.Errorf("expected the last payload seen, got %v", data)
}
if srv.Polls != 3 {
t.Errorf("polled %d times, want PollAttempts=3", srv.Polls)
}
}
// TestRealtimeStopsOnCancelledContext keeps a hung scrape from outliving its
// timeout: the poll loop has to watch the context, not just sleep.
func TestRealtimeStopsOnCancelledContext(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 1000
cfg := testConfig(srv.URL)
cfg.PollAttempts = 1000
cfg.PollInterval = 10 * time.Millisecond
c := newTestClient(t, cfg)
if err := c.EnsureSession(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() {
c.Realtime(ctx, bydtest.VIN, "0")
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Realtime ignored the cancelled context")
}
}
func TestLoginFailureReturnsAPIError(t *testing.T) {
srv := bydtest.New(t)
srv.LoginCode = "1003"
err := newTestClient(t, testConfig(srv.URL)).Login(context.Background())
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("got %T (%v), want *APIError", err, err)
}
if apiErr.Code != "1003" {
t.Errorf("Code = %q, want 1003", apiErr.Code)
}
if !strings.Contains(apiErr.Error(), loginEndpoint) {
t.Errorf("error does not name the endpoint: %v", apiErr)
}
}
// TestEnsureSessionReusesUntilTTL: a fresh login per scrape would be both slow
// and a good way to get the account flagged.
func TestEnsureSessionReusesUntilTTL(t *testing.T) {
srv := bydtest.New(t)
cfg := testConfig(srv.URL)
cfg.SessionTTL = time.Hour
c := newTestClient(t, cfg)
now := time.UnixMilli(1700000000000)
c.now = func() time.Time { return now }
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
}
if srv.Logins != 1 {
t.Errorf("logged in %d times, want 1 while inside the TTL", srv.Logins)
}
now = now.Add(2 * time.Hour)
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
if srv.Logins != 2 {
t.Errorf("logged in %d times, want a re-login once the TTL passed", srv.Logins)
}
}
func TestEndpointsRequireLogin(t *testing.T) {
c := newTestClient(t, testConfig("http://example.invalid"))
if _, err := c.Vehicles(context.Background()); err == nil {
t.Error("expected an error when calling an endpoint before logging in")
}
}
func TestRealtimeReady(t *testing.T) {
tests := []struct {
name string
info map[string]any
want bool
}{
{"empty", nil, false},
{"ack only", map[string]any{"requestSerial": "abc"}, false},
{"offline wins over data", map[string]any{"onlineState": 2.0, "enduranceMileage": 300.0}, false},
{"tyre pressure", map[string]any{"leftFrontTirepressure": 240.0}, true},
{"rear tyre pressure", map[string]any{"rightRearTirepressure": 235.0}, true},
{"timestamp", map[string]any{"time": 1.7e12}, true},
{"range", map[string]any{"enduranceMileage": 391.0}, true},
{"zero range only", map[string]any{"enduranceMileage": 0.0}, false},
{"range as a string", map[string]any{"enduranceMileage": "391"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RealtimeReady(tt.info); got != tt.want {
t.Errorf("RealtimeReady(%v) = %v, want %v", tt.info, got, tt.want)
}
})
}
}
func TestVehicleName(t *testing.T) {
tests := []struct {
v Vehicle
want string
}{
{Vehicle{AutoAlias: "Daily", AutoPlate: "AB-123", ModelName: "Sealion 7"}, "Daily"},
{Vehicle{AutoPlate: "AB-123", ModelName: "Sealion 7"}, "AB-123"},
{Vehicle{ModelName: "Sealion 7"}, "Sealion 7"},
{Vehicle{VIN: "LC0"}, "LC0"},
}
for _, tt := range tests {
if got := tt.v.Name(); got != tt.want {
t.Errorf("Name() = %q, want %q", got, tt.want)
}
}
}