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

147 lines
3.9 KiB
Go

package byd
// Expected values come from pybyd 0.0.75 — regenerate with testdata/gen_fixtures.py.
import (
"strings"
"testing"
)
func TestHashing(t *testing.T) {
tests := []struct {
name string
got string
want string
}{
{"md5Hex", md5Hex("hunter2"), "2AB96390C7DBE3439DE74D0C9B0B1767"},
{"pwdLoginKey", pwdLoginKey("hunter2"), "38075EADB88F8484DDC00C4D272D9F4C"},
{"sha1Mixed", sha1Mixed("abc"), "A9993E36476816aBA3e25717850C26c9Cd0D89d"},
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("%s = %s, want %s", tt.name, tt.got, tt.want)
}
}
}
// TestSha1MixedDropsZeros covers the odd half of the algorithm. Note the
// filtering is indexed against the pre-filter string, not the result, so the
// output can still contain '0' at an even position — the only thing guaranteed
// is that it comes out shorter than a plain 40-char hex digest.
func TestSha1MixedDropsZeros(t *testing.T) {
got := sha1Mixed("abc")
if len(got) >= 40 {
t.Errorf("sha1Mixed(%q) = %q (%d chars); zero filtering did not run", "abc", got, len(got))
}
if strings.ToLower(got) == got || strings.ToUpper(got) == got {
t.Errorf("sha1Mixed(%q) = %q; expected mixed case", "abc", got)
}
}
func TestJmapPreservesInsertionOrder(t *testing.T) {
// A Go map would sort these keys and change the checkcode, so this is the
// guard on the ordered marshalling the whole protocol depends on.
m := jmap{}
m.set("zeta", "1")
m.set("alpha", "2")
b, err := marshalCompact(m)
if err != nil {
t.Fatal(err)
}
if want := `{"zeta":"1","alpha":"2"}`; string(b) != want {
t.Fatalf("marshalCompact = %s, want %s", b, want)
}
got, err := checkcode(m)
if err != nil {
t.Fatal(err)
}
if want := "3d6e01c0671c8e4d2e2fbda3466886b0"; got != want {
t.Errorf("checkcode = %s, want %s", got, want)
}
}
func TestJmapSetOverwritesInPlace(t *testing.T) {
m := jmap{}
m.set("a", "1")
m.set("b", "2")
m.set("a", "3") // must update, not append, or the order shifts
b, _ := marshalCompact(m)
if want := `{"a":"3","b":"2"}`; string(b) != want {
t.Errorf("marshalCompact = %s, want %s", b, want)
}
}
func TestJmapCloneIsIndependent(t *testing.T) {
orig := jmap{}
orig.set("a", "1")
clone := orig.clone()
clone.set("b", "2")
clone.set("a", "changed")
if orig.get("a") != "1" || len(orig) != 1 {
t.Errorf("clone mutated the original: %v", orig)
}
}
// TestMarshalCompactDoesNotEscapeHTML guards a real difference between Go and
// Python: encoding/json escapes < > & by default, which would change every
// checkcode for a payload containing them.
func TestMarshalCompactDoesNotEscapeHTML(t *testing.T) {
m := jmap{}
m.set("q", "a<b&c>d")
b, err := marshalCompact(m)
if err != nil {
t.Fatal(err)
}
if want := `{"q":"a<b&c>d"}`; string(b) != want {
t.Errorf("marshalCompact = %s, want %s", b, want)
}
}
func TestBuildSignStringSortsKeys(t *testing.T) {
m := jmap{}
m.set("b", "2")
m.set("a", "1")
m.set("c", "3")
if got, want := buildSignString(m, "pw"), "a=1&b=2&c=3&password=pw"; got != want {
t.Errorf("buildSignString = %q, want %q", got, want)
}
}
func TestAESRoundTrip(t *testing.T) {
key := pwdLoginKey("hunter2")
const plain = `{"a":1,"b":"ü"}`
ct, err := aesEncryptHex(plain, key)
if err != nil {
t.Fatal(err)
}
got, err := aesDecryptUTF8(ct, key)
if err != nil {
t.Fatal(err)
}
if got != plain {
t.Errorf("round trip = %q, want %q", got, plain)
}
}
func TestAESRejectsBadInput(t *testing.T) {
key := pwdLoginKey("hunter2")
if _, err := aesEncryptHex("x", "not-hex"); err == nil {
t.Error("expected an error for a non-hex key")
}
if _, err := aesEncryptHex("x", "AABB"); err == nil {
t.Error("expected an error for a 2-byte key")
}
if _, err := aesDecryptUTF8("zz", key); err == nil {
t.Error("expected an error for non-hex ciphertext")
}
if _, err := aesDecryptUTF8("AABB", key); err == nil {
t.Error("expected an error for a partial block")
}
}