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

+125
View File
@@ -0,0 +1,125 @@
// Package exporter runs the scrape on a schedule and serves the last result.
//
// Scraping is scheduled rather than done per Prometheus request on purpose:
// each reading wakes the car's T-Box over the mobile network and drains the
// traction battery (roughly 0.1 kWh/h at a 5-minute interval on a 60 kWh pack,
// per the Home Assistant integration). Prometheus can scrape /metrics as often
// as it likes; only the schedule reaches the car.
package exporter
import (
"context"
"fmt"
"log"
"net/http"
"sync"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd"
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
"git.tinetunnel.com/thistine/byd-exporter/internal/metrics"
)
// MetricPrefix is prepended to every field name from the realtime payload.
const MetricPrefix = "byd_"
// Exporter holds the last scrape so /metrics can serve it without touching the car.
type Exporter struct {
client *byd.Client
log *log.Logger
mu sync.RWMutex
latest string
}
func New(client *byd.Client, logger *log.Logger) *Exporter {
if logger == nil {
logger = log.Default()
}
return &Exporter{client: client, log: logger}
}
// Scrape reads every vehicle and replaces the served metrics. It always
// publishes something — on failure, byd_scrape_success 0 — so that a broken
// scrape is visible in Prometheus rather than silently serving stale data.
func (e *Exporter) Scrape(ctx context.Context) error {
start := time.Now()
set := metrics.New()
err := e.collect(ctx, set)
if err != nil {
e.log.Printf("scrape failed: %v", err)
}
success := 0.0
if err == nil {
success = 1
}
set.Add("byd_scrape_success", nil, success)
set.Add("byd_scrape_duration_seconds", nil, time.Since(start).Seconds())
set.Add("byd_scrape_timestamp_seconds", nil, float64(start.Unix()))
e.mu.Lock()
e.latest = set.String()
e.mu.Unlock()
return err
}
// collect fills set with one reading per vehicle. A single unreachable car is
// reported as byd_vehicle_up 0 and does not stop the others.
func (e *Exporter) collect(ctx context.Context, set *metrics.Set) error {
if err := e.client.EnsureSession(ctx); err != nil {
return fmt.Errorf("login: %w", err)
}
vehicles, err := e.client.Vehicles(ctx)
if err != nil {
return fmt.Errorf("vehicle list: %w", err)
}
set.Add("byd_vehicles", nil, float64(len(vehicles)))
var firstErr error
for _, v := range vehicles {
if v.VIN == "" {
continue
}
labels := metrics.Labels{"vin": v.VIN, "name": v.Name()}
set.Add("byd_vehicle_info", metrics.Labels{
"vin": v.VIN,
"name": v.Name(),
"model": v.ModelName,
"brand": v.BrandName,
"plate": v.AutoPlate,
}, 1)
energyType := jsonv.Str(v.EnergyType)
if energyType == "" {
energyType = "0" // EV
}
data, err := e.client.Realtime(ctx, v.VIN, energyType)
if err != nil {
e.log.Printf("realtime %s: %v", v.VIN, err)
set.Add("byd_vehicle_up", labels, 0)
if firstErr == nil {
firstErr = err
}
continue
}
set.Add("byd_vehicle_up", labels, 1)
set.AddPayload(MetricPrefix, labels, data)
}
return firstErr
}
// Metrics returns the last rendered exposition.
func (e *Exporter) Metrics() string {
e.mu.RLock()
defer e.mu.RUnlock()
return e.latest
}
func (e *Exporter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprint(w, e.Metrics())
}
+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()
}
}
}