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

+150
View File
@@ -0,0 +1,150 @@
package metrics
import (
"encoding/json"
"strings"
"testing"
)
func TestSnake(t *testing.T) {
tests := map[string]string{
"soc": "soc",
"enduranceMileage": "endurance_mileage",
"leftFrontTirepressure": "left_front_tirepressure",
"mainSettingTempNew": "main_setting_temp_new",
"evSOC": "ev_soc",
"evSOCValue": "ev_soc_value",
"tboxVersion": "tbox_version",
"gear2State": "gear2_state",
"": "",
}
for in, want := range tests {
if got := Snake(in); got != want {
t.Errorf("Snake(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidName(t *testing.T) {
valid := []string{"byd_soc", "_x", "a1"}
invalid := []string{"", "1abc", "byd-soc", "byd soc", "byd.soc"}
for _, s := range valid {
if !ValidName(s) {
t.Errorf("ValidName(%q) = false, want true", s)
}
}
for _, s := range invalid {
if ValidName(s) {
t.Errorf("ValidName(%q) = true, want false", s)
}
}
}
// TestAddPayloadCoercion covers the shapes BYD actually sends: JSON numbers,
// numbers quoted as strings, booleans, and strings that are not numbers at all.
func TestAddPayloadCoercion(t *testing.T) {
var payload map[string]any
raw := `{"elecPercent":82,"speed":"13.5","charging":true,"vin":"LC0","nested":{"a":1},"missing":null}`
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
t.Fatal(err)
}
s := New()
s.AddPayload("byd_", Labels{"vin": "LC0"}, payload)
want := map[string]float64{
"byd_elec_percent": 82,
"byd_speed": 13.5,
"byd_charging": 1,
}
for name, value := range want {
samples := s.byName[name]
if len(samples) != 1 {
t.Errorf("%s: got %d samples, want 1", name, len(samples))
continue
}
if samples[0].value != value {
t.Errorf("%s = %v, want %v", name, samples[0].value, value)
}
}
for _, name := range []string{"byd_vin", "byd_nested", "byd_missing"} {
if _, ok := s.byName[name]; ok {
t.Errorf("%s should not have become a metric", name)
}
}
}
func TestStringFormat(t *testing.T) {
s := New()
s.Add("byd_soc", Labels{"vin": "B", "name": "Second"}, 50)
s.Add("byd_soc", Labels{"vin": "A", "name": "First"}, 82)
s.Add("byd_scrape_success", nil, 1)
got := s.String()
want := strings.Join([]string{
"# TYPE byd_scrape_success gauge",
"byd_scrape_success 1",
"# TYPE byd_soc gauge",
`byd_soc{name="First",vin="A"} 82`,
`byd_soc{name="Second",vin="B"} 50`,
"",
}, "\n")
if got != want {
t.Errorf("String() =\n%s\nwant\n%s", got, want)
}
}
// TestStringEmitsOneTypeLinePerName guards the rule that repeating a TYPE line
// for the same metric makes the exposition invalid for strict parsers.
func TestStringEmitsOneTypeLinePerName(t *testing.T) {
s := New()
for _, vin := range []string{"A", "B", "C"} {
s.Add("byd_soc", Labels{"vin": vin}, 1)
}
if n := strings.Count(s.String(), "# TYPE byd_soc gauge"); n != 1 {
t.Errorf("got %d TYPE lines for byd_soc, want 1", n)
}
}
func TestStringIsStable(t *testing.T) {
build := func() string {
s := New()
s.AddPayload("byd_", Labels{"vin": "A"}, map[string]any{
"soc": 1.0, "range": 2.0, "speed": 3.0, "temp": 4.0,
})
return s.String()
}
// Map iteration order differs per run; the rendered output must not.
first := build()
for i := 0; i < 20; i++ {
if got := build(); got != first {
t.Fatalf("output is not stable across runs:\n%s\nvs\n%s", got, first)
}
}
}
func TestLabelEscaping(t *testing.T) {
s := New()
s.Add("byd_x", Labels{"name": `He said "hi"\`, "empty": ""}, 1)
got := s.String()
if !strings.Contains(got, `name="He said \"hi\"\\"`) {
t.Errorf("quotes and backslashes not escaped: %s", got)
}
if strings.Contains(got, "empty") {
t.Errorf("empty labels should be dropped: %s", got)
}
}
func TestLen(t *testing.T) {
s := New()
if s.Len() != 0 {
t.Errorf("Len() = %d on a new set, want 0", s.Len())
}
s.Add("a", nil, 1)
s.Add("a", Labels{"vin": "x"}, 2)
s.Add("b", nil, 3)
if s.Len() != 3 {
t.Errorf("Len() = %d, want 3", s.Len())
}
}