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

+134
View File
@@ -0,0 +1,134 @@
// Command byd-exporter scrapes vehicle telemetry from the BYD cloud on a cron
// schedule and serves it as Prometheus metrics.
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/robfig/cron/v3"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd"
"git.tinetunnel.com/thistine/byd-exporter/internal/exporter"
)
// scrapeTimeout bounds one scrape. A sleeping car is polled for a while before
// giving up, so this is generous relative to the work.
const scrapeTimeout = 5 * time.Minute
func main() {
once := flag.Bool("once", false, "scrape once, print metrics to stdout and exit")
flag.Parse()
cfg, err := configFromEnv()
if err != nil {
log.Fatal(err)
}
client, err := byd.New(cfg)
if err != nil {
log.Fatal(err)
}
exp := exporter.New(client, log.Default())
if *once {
err := scrape(exp)
fmt.Print(exp.Metrics())
if err != nil {
os.Exit(1)
}
return
}
schedule := env("BYD_SCHEDULE", "0 * * * *")
parsed, err := cron.ParseStandard(schedule)
if err != nil {
log.Fatalf("BYD_SCHEDULE %q: %v", schedule, err)
}
// Fill /metrics before the first tick, so a restart does not serve an empty
// body until the next scheduled run.
go scrape(exp)
runner := cron.New()
runner.Schedule(parsed, cron.FuncJob(func() { scrape(exp) }))
runner.Start()
defer runner.Stop()
listen := env("BYD_LISTEN", ":9838")
mux := http.NewServeMux()
mux.Handle("/metrics", exp)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
log.Printf("byd-exporter listening on %s, schedule %q, endpoint %s", listen, schedule, cfg.BaseURL)
log.Fatal(http.ListenAndServe(listen, mux))
}
func scrape(exp *exporter.Exporter) error {
ctx, cancel := context.WithTimeout(context.Background(), scrapeTimeout)
defer cancel()
return exp.Scrape(ctx)
}
func configFromEnv() (byd.Config, error) {
username, password := os.Getenv("BYD_USERNAME"), os.Getenv("BYD_PASSWORD")
if username == "" || password == "" {
return byd.Config{}, fmt.Errorf("BYD_USERNAME and BYD_PASSWORD are required")
}
// The device identity is derived from a seed rather than stored, so it
// survives restarts without a volume. See byd.NewDevice.
device, err := byd.NewDevice(env("BYD_DEVICE_SEED", username))
if err != nil {
return byd.Config{}, err
}
region := env("BYD_REGION", "eu")
return byd.Config{
Username: username,
Password: password,
BaseURL: env("BYD_BASE_URL", "https://dilinkappoversea-"+region+".byd.auto"),
CountryCode: env("BYD_COUNTRY_CODE", "NL"),
Language: env("BYD_LANGUAGE", "en"),
TimeZone: env("BYD_TIME_ZONE", "UTC"),
AppName: env("BYD_APP_NAME", "pyBYD+0.0.75"),
AppVersion: env("BYD_APP_VERSION", "3.5.1"),
AppInnerVersion: env("BYD_APP_INNER_VERSION", "351"),
SoftType: "0",
TboxVersion: env("BYD_TBOX_VERSION", "3"),
IsAuto: "1",
SessionTTL: envDuration("BYD_SESSION_TTL", 12*time.Hour),
PollAttempts: envInt("BYD_POLL_ATTEMPTS", 10),
PollInterval: envDuration("BYD_POLL_INTERVAL", 3*time.Second),
Device: device,
}, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v, err := strconv.Atoi(os.Getenv(key)); err == nil {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if v, err := time.ParseDuration(os.Getenv(key)); err == nil {
return v
}
return fallback
}