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

+109
View File
@@ -0,0 +1,109 @@
package byd
import (
"encoding/json"
"regexp"
"strconv"
"testing"
)
// TestDeviceIsStable is the point of the whole seeded-derivation approach: a
// restart must not look like a new phone to BYD.
func TestDeviceIsStable(t *testing.T) {
a, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
b, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if a != b {
t.Errorf("same seed gave different devices:\n%+v\n%+v", a, b)
}
c, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if c.IMEI == a.IMEI {
t.Error("different seeds produced the same IMEI")
}
}
func TestDeviceLooksRealistic(t *testing.T) {
d, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^\d{15}$`).MatchString(d.IMEI) {
t.Errorf("IMEI %q is not 15 digits", d.IMEI)
}
if got, want := luhn(d.IMEI[:14]), string(d.IMEI[14]); got != want {
t.Errorf("IMEI %q fails the Luhn check (want check digit %s)", d.IMEI, got)
}
if !regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`).MatchString(d.MAC) {
t.Errorf("MAC %q is malformed", d.MAC)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d.IMEIMD5) {
t.Errorf("IMEIMD5 %q is not a lowercase MD5", d.IMEIMD5)
}
for name, v := range map[string]string{
"Model": d.Model, "MobileBrand": d.MobileBrand, "MobileModel": d.MobileModel,
"Mod": d.Mod, "SDK": d.SDK, "AndroidVer": d.AndroidVer,
} {
if v == "" {
t.Errorf("%s is empty", name)
}
}
}
// TestDeviceMACIsLocallyAdministered keeps the generated address out of the
// space of real vendor MACs.
func TestDeviceMACIsLocallyAdministered(t *testing.T) {
for _, seed := range []string{"a", "b", "c", "d", "e"} {
d, err := NewDevice(seed)
if err != nil {
t.Fatal(err)
}
first, err := strconv.ParseInt(d.MAC[:2], 16, 16)
if err != nil {
t.Fatalf("MAC %q: %v", d.MAC, err)
}
if first&0x02 == 0 {
t.Errorf("MAC %q is not locally administered", d.MAC)
}
if first&0x01 != 0 {
t.Errorf("MAC %q is a multicast address", d.MAC)
}
}
}
func TestDevicePoolIsUsable(t *testing.T) {
var pool []poolEntry
if err := json.Unmarshal(devicePoolJSON, &pool); err != nil {
t.Fatalf("device_pool.json is not valid JSON: %v", err)
}
if len(pool) < 2 {
t.Fatalf("device pool has %d entries; a pool of one defeats the point", len(pool))
}
for i, e := range pool {
if len(e.TACPrefix) != 8 {
t.Errorf("entry %d: TAC prefix %q is not 8 digits", i, e.TACPrefix)
}
if len(e.SDKOptions) == 0 || len(e.SDKOptions) != len(e.OSOptions) {
t.Errorf("entry %d: sdk_options and os_options must pair up (%d vs %d)",
i, len(e.SDKOptions), len(e.OSOptions))
}
}
}
func TestLuhn(t *testing.T) {
// Known-good IMEIs; the 15th digit must be what luhn derives from the first 14.
for _, imei := range []string{"490154203237518", "356938035643809"} {
if got, want := luhn(imei[:14]), string(imei[14]); got != want {
t.Errorf("luhn(%s) = %s, want %s", imei[:14], got, want)
}
}
}