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:
commit
52e1aaa0c0
30 files changed
+3501
No files matched your search
@@ -0,0 +1,146 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user