// 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()) }