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

185 lines
4.5 KiB
Go

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
}