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