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