Files
thistineandClaude Opus 5 52e1aaa0c0
Test / test (push) Failing after 25m47s
Add BYD vehicle telemetry exporter
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]>
2026-09-05 22:03:16 +07:00

126 lines
3.3 KiB
Go

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