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