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

+165
View File
@@ -0,0 +1,165 @@
// Package metrics renders a Prometheus text exposition by hand.
//
// A client library would buy registration, collectors and concurrency handling
// that a snapshot of ~200 gauges does not need — the whole format is three line
// shapes, and this way the metric names can come from the payload at runtime.
package metrics
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
)
// Labels is a metric's label set. Empty values are dropped on render.
type Labels map[string]string
type sample struct {
labels Labels
value float64
}
// Set is one scrape's worth of gauges, grouped by metric name so that a name
// shared across vehicles emits a single TYPE line.
type Set struct {
byName map[string][]sample
}
func New() *Set { return &Set{byName: map[string][]sample{}} }
func (s *Set) Add(name string, labels Labels, value float64) {
s.byName[name] = append(s.byName[name], sample{labels, value})
}
// AddPayload emits every numeric field of a BYD payload as prefix + snake(key).
//
// Nothing here is a hand-maintained field list: BYD changes the realtime
// payload between firmware versions, and new fields should appear on their own.
// Non-numeric fields (strings, nested objects) are skipped — a metric can only
// hold a float.
func (s *Set) AddPayload(prefix string, labels Labels, payload map[string]any) {
for k, v := range payload {
f, ok := jsonv.Num(v)
if !ok {
continue
}
if name := prefix + Snake(k); ValidName(name) {
s.Add(name, labels, f)
}
}
}
// Len reports how many samples the set holds.
func (s *Set) Len() int {
n := 0
for _, samples := range s.byName {
n += len(samples)
}
return n
}
// String renders the exposition. Names are sorted, and samples within a name
// are sorted by vin, so the output is stable between scrapes and diffable.
func (s *Set) String() string {
names := make([]string, 0, len(s.byName))
for n := range s.byName {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, "# TYPE %s gauge\n", n)
samples := s.byName[n]
sort.SliceStable(samples, func(i, j int) bool {
return samples[i].labels["vin"] < samples[j].labels["vin"]
})
for _, sm := range samples {
b.WriteString(n)
b.WriteString(formatLabels(sm.labels))
b.WriteByte(' ')
b.WriteString(formatValue(sm.value))
b.WriteByte('\n')
}
}
return b.String()
}
// formatValue keeps whole numbers readable: a unix timestamp should render as
// 1757000000, not 1.757e+09. Both are valid Prometheus, one is greppable.
func formatValue(v float64) string {
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
func formatLabels(labels Labels) string {
keys := make([]string, 0, len(labels))
for k, v := range labels {
if v != "" {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return ""
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
// Quotes are added here rather than with %q, which would escape the
// backslashes escapeLabel just added.
parts = append(parts, k+`="`+escapeLabel(labels[k])+`"`)
}
return "{" + strings.Join(parts, ",") + "}"
}
func escapeLabel(v string) string {
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(v)
}
// ValidName reports whether s is a legal Prometheus metric name.
func ValidName(s string) bool {
if s == "" {
return false
}
for i, c := range s {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
case c >= '0' && c <= '9' && i > 0:
default:
return false
}
}
return true
}
// Snake converts camelCase to snake_case: "leftFrontTirepressure" becomes
// "left_front_tirepressure" and "evSOC" becomes "ev_soc".
func Snake(s string) string {
var b strings.Builder
runes := []rune(s)
for i, r := range runes {
isUpper := r >= 'A' && r <= 'Z'
if isUpper && i > 0 {
prev := runes[i-1]
prevLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9')
// A capital starting a new word inside a run of capitals, as the C
// in "evSOCValue".
capThenWord := prev >= 'A' && prev <= 'Z' && i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z'
if prevLowerOrDigit || capThenWord {
b.WriteByte('_')
}
}
if isUpper {
r += 'a' - 'A'
}
b.WriteRune(r)
}
return b.String()
}