Add BYD vehicle telemetry exporter
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]>
This commit is contained in:
thistineandClaude Opus 5 committed 2026-09-05 22:03:16 +07:00
commit 52e1aaa0c0
30 files changed
+3501

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
package exporter
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest"
)
func newExporter(t *testing.T, baseURL string) *Exporter {
t.Helper()
client, err := byd.New(byd.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: 3, PollInterval: time.Millisecond,
Device: mustDevice(t),
})
if err != nil {
t.Fatal(err)
}
return New(client, log.New(io.Discard, "", 0))
}
func mustDevice(t *testing.T) byd.Device {
t.Helper()
d, err := byd.NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
return d
}
// TestScrapeEndToEnd runs login, vehicle list, realtime and rendering against a
// fake cloud that speaks the real protocol.
func TestScrapeEndToEnd(t *testing.T) {
srv := bydtest.New(t)
exp := newExporter(t, srv.URL)
if err := exp.Scrape(context.Background()); err != nil {
t.Fatalf("Scrape: %v", err)
}
got := exp.Metrics()
wantLines := []string{
`byd_elec_percent{name="Daily",vin="` + bydtest.VIN + `"} 82`,
`byd_endurance_mileage{name="Daily",vin="` + bydtest.VIN + `"} 391`,
`byd_total_mileage{name="Daily",vin="` + bydtest.VIN + `"} 14203`,
`byd_left_front_tirepressure{name="Daily",vin="` + bydtest.VIN + `"} 240`,
`byd_vehicle_up{name="Daily",vin="` + bydtest.VIN + `"} 1`,
`byd_vehicles 1`,
`byd_scrape_success 1`,
}
for _, line := range wantLines {
if !strings.Contains(got, line) {
t.Errorf("missing line %q in:\n%s", line, got)
}
}
if !strings.Contains(got, `byd_vehicle_info{brand="BYD",model="SEALION 7",name="Daily",plate="AB-1234",vin="`) {
t.Errorf("byd_vehicle_info labels are wrong:\n%s", got)
}
// The VIN is a string field of the payload; it must not become a metric.
if strings.Contains(got, "byd_vin") {
t.Errorf("string field leaked into a metric:\n%s", got)
}
}
// TestScrapeFailurePublishesZero: a broken scrape must still publish, so the
// failure is visible in Prometheus rather than hiding behind stale numbers.
func TestScrapeFailurePublishesZero(t *testing.T) {
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream is down", http.StatusBadGateway)
}))
defer dead.Close()
exp := newExporter(t, dead.URL)
if err := exp.Scrape(context.Background()); err == nil {
t.Fatal("expected an error from a failing upstream")
}
got := exp.Metrics()
if !strings.Contains(got, "byd_scrape_success 0") {
t.Errorf("expected byd_scrape_success 0, got:\n%s", got)
}
if !strings.Contains(got, "byd_scrape_timestamp_seconds") {
t.Errorf("expected a timestamp even on failure, got:\n%s", got)
}
}
// TestScrapeSurvivesOneBadVehicle: one car that cannot be read must not cost
// the metrics of the others.
func TestScrapeSurvivesOneBadVehicle(t *testing.T) {
srv := bydtest.New(t)
srv.Vehicles = append(bydtest.DefaultVehicles(), map[string]any{
"vin": "", // skipped: no VIN to label with
"modelName": "Ghost",
})
exp := newExporter(t, srv.URL)
if err := exp.Scrape(context.Background()); err != nil {
t.Fatalf("Scrape: %v", err)
}
got := exp.Metrics()
if !strings.Contains(got, "byd_vehicles 2") {
t.Errorf("expected both vehicles counted:\n%s", got)
}
if strings.Contains(got, `vin=""`) {
t.Errorf("a vehicle with no VIN should be skipped:\n%s", got)
}
if !strings.Contains(got, "byd_elec_percent") {
t.Errorf("the good vehicle's metrics went missing:\n%s", got)
}
}
func TestServeHTTP(t *testing.T) {
srv := bydtest.New(t)
exp := newExporter(t, srv.URL)
// Before any scrape the body is empty but the request must still succeed,
// so Prometheus sees an up target rather than a connection error.
rec := httptest.NewRecorder()
exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
if err := exp.Scrape(context.Background()); err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if !strings.Contains(rec.Body.String(), "byd_elec_percent") {
t.Errorf("body has no metrics:\n%s", rec.Body.String())
}
}
// TestConcurrentServeAndScrape is a race-detector target: /metrics is served
// while a scrape is replacing it.
func TestConcurrentServeAndScrape(t *testing.T) {
srv := bydtest.New(t)
exp := newExporter(t, srv.URL)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 3; i++ {
exp.Scrape(context.Background())
}
}()
for {
select {
case <-done:
return
default:
_ = exp.Metrics()
}
}
}