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