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,184 @@
|
||||
package byd
|
||||
|
||||
// Signing and hashing primitives, ported from pybyd's
|
||||
// _crypto/{aes,hashing,signing}.py. None of this is our design — it exists to
|
||||
// reproduce what the app sends, byte for byte.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.tinetunnel.com/thistine/byd-exporter/internal/bangcle"
|
||||
)
|
||||
|
||||
// jmap is an insertion-ordered JSON object. Order matters: the outer payload's
|
||||
// checkcode is an MD5 over the serialised object, so re-ordering the keys
|
||||
// changes it. A Go map would sort them and break the hash.
|
||||
type jmap []jfield
|
||||
|
||||
type jfield struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
func (m *jmap) set(k string, v any) {
|
||||
for i := range *m {
|
||||
if (*m)[i].Key == k {
|
||||
(*m)[i].Value = v
|
||||
return
|
||||
}
|
||||
}
|
||||
*m = append(*m, jfield{k, v})
|
||||
}
|
||||
|
||||
func (m jmap) get(k string) string {
|
||||
for _, f := range m {
|
||||
if f.Key == k {
|
||||
s, _ := f.Value.(string)
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m jmap) clone() jmap { return append(jmap{}, m...) }
|
||||
|
||||
func (m jmap) MarshalJSON() ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, f := range m {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
k, err := marshalCompact(f.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := marshalCompact(f.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Write(k)
|
||||
b.WriteByte(':')
|
||||
b.Write(v)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// marshalCompact matches Python's json.dumps(separators=(",",":"),
|
||||
// ensure_ascii=False): no HTML escaping of < > &, no trailing newline.
|
||||
func marshalCompact(v any) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
enc := json.NewEncoder(&b)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.TrimRight(b.Bytes(), "\n"), nil
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
sum := md5.Sum([]byte(s))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// pwdLoginKey derives the login AES key from the plaintext password.
|
||||
func pwdLoginKey(password string) string { return md5Hex(md5Hex(password)) }
|
||||
|
||||
// sha1Mixed renders SHA1 with alternating byte case, then drops every '0' at an
|
||||
// even index of the resulting string.
|
||||
func sha1Mixed(s string) string {
|
||||
sum := sha1.Sum([]byte(s))
|
||||
var mixed strings.Builder
|
||||
for i, b := range sum {
|
||||
h := fmt.Sprintf("%02x", b)
|
||||
if i%2 == 0 {
|
||||
h = strings.ToUpper(h)
|
||||
}
|
||||
mixed.WriteString(h)
|
||||
}
|
||||
var out strings.Builder
|
||||
for i, c := range mixed.String() {
|
||||
if c == '0' && i%2 == 0 {
|
||||
continue
|
||||
}
|
||||
out.WriteRune(c)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// checkcode is the MD5 of the compact JSON with its four 8-char chunks reordered.
|
||||
func checkcode(payload jmap) (string, error) {
|
||||
b, err := marshalCompact(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := md5.Sum(b)
|
||||
h := hex.EncodeToString(sum[:])
|
||||
return h[24:32] + h[8:16] + h[16:24] + h[0:8], nil
|
||||
}
|
||||
|
||||
// buildSignString joins the fields sorted by key and appends the secret.
|
||||
func buildSignString(fields jmap, secret string) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
keys = append(keys, f.Key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+fields.get(k))
|
||||
}
|
||||
return strings.Join(parts, "&") + "&password=" + secret
|
||||
}
|
||||
|
||||
// aesEncryptHex is the inner payload layer: AES-CBC with a zero IV and a
|
||||
// hex-encoded key, returning uppercase hex.
|
||||
func aesEncryptHex(plaintext, keyHex string) (string, error) {
|
||||
block, err := aesCipher(keyHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := bangcle.PadPKCS7([]byte(plaintext))
|
||||
out := make([]byte, len(data))
|
||||
cipher.NewCBCEncrypter(block, make([]byte, 16)).CryptBlocks(out, data)
|
||||
return strings.ToUpper(hex.EncodeToString(out)), nil
|
||||
}
|
||||
|
||||
func aesDecryptUTF8(cipherHex, keyHex string) (string, error) {
|
||||
block, err := aesCipher(keyHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct, err := hex.DecodeString(cipherHex)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("aes: ciphertext must be hex: %w", err)
|
||||
}
|
||||
if len(ct) == 0 || len(ct)%16 != 0 {
|
||||
return "", fmt.Errorf("aes: ciphertext length %d is not a positive multiple of 16", len(ct))
|
||||
}
|
||||
out := make([]byte, len(ct))
|
||||
cipher.NewCBCDecrypter(block, make([]byte, 16)).CryptBlocks(out, ct)
|
||||
return string(bangcle.StripPKCS7(out)), nil
|
||||
}
|
||||
|
||||
func aesCipher(keyHex string) (cipher.Block, error) {
|
||||
key, err := hex.DecodeString(strings.TrimPrefix(strings.TrimPrefix(keyHex, "0x"), "0X"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes: key must be hex: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes: %w", err)
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
Reference in new issue
Block a user