Files
byd-exporter/internal/byd/device.go
T
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

127 lines
3.2 KiB
Go

package byd
// Android device fingerprint sent with every request, ported from the HA
// integration's device_fingerprint.py.
//
// The profile must stay STABLE for an account: BYD treats a new IMEI as a new
// device, and a fingerprint that changes on every restart looks like account
// sharing. Rather than persisting state, it is derived deterministically from a
// seed (the username by default) — same account, same phone, forever.
import (
"crypto/md5"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"hash/fnv"
"math/rand"
"strings"
)
//go:embed device_pool.json
var devicePoolJSON []byte
// Device is the phone identity presented to the API.
type Device struct {
OSType string // outer "ostype", always "and"
IMEI string
MAC string
Model string
SDK string
Mod string
IMEIMD5 string
MobileBrand string
MobileModel string
DeviceType string // "0"
NetworkType string // "wifi"
AndroidVer string // Android release, sent as the inner payload's "osType"
OSVersion string // SDK level, sent as the inner payload's "osVersion"
}
type poolEntry struct {
MobileBrand string `json:"mobile_brand"`
MobileModel string `json:"mobile_model"`
Model string `json:"model"`
Mod string `json:"mod"`
TACPrefix string `json:"tac_prefix"`
SDKOptions []string `json:"sdk_options"`
OSOptions []string `json:"os_options"`
}
// NewDevice derives a stable, realistic device profile from seed.
func NewDevice(seed string) (Device, error) {
var pool []poolEntry
if err := json.Unmarshal(devicePoolJSON, &pool); err != nil {
return Device{}, fmt.Errorf("device pool: %w", err)
}
if len(pool) == 0 {
return Device{}, fmt.Errorf("device pool is empty")
}
h := fnv.New64a()
h.Write([]byte(seed))
rng := rand.New(rand.NewSource(int64(h.Sum64())))
e := pool[rng.Intn(len(pool))]
i := rng.Intn(len(e.SDKOptions))
imei := generateIMEI(rng, e.TACPrefix)
sum := md5.Sum([]byte(imei))
return Device{
OSType: "and",
IMEI: imei,
MAC: generateMAC(rng),
Model: e.Model,
SDK: e.SDKOptions[i],
Mod: e.Mod,
IMEIMD5: hex.EncodeToString(sum[:]),
MobileBrand: e.MobileBrand,
MobileModel: e.MobileModel,
DeviceType: "0",
NetworkType: "wifi",
AndroidVer: e.OSOptions[i],
OSVersion: e.SDKOptions[i],
}, nil
}
// generateIMEI builds a valid 15-digit IMEI: a real 8-digit TAC identifying the
// handset model, a 6-digit serial, and a Luhn check digit.
func generateIMEI(rng *rand.Rand, tacPrefix string) string {
var b strings.Builder
b.WriteString(tacPrefix)
for n := 0; n < 6; n++ {
fmt.Fprint(&b, rng.Intn(10))
}
return b.String() + luhn(b.String())
}
// luhn returns the check digit for the first 14 IMEI digits.
func luhn(partial string) string {
total := 0
for i, c := range partial {
d := int(c - '0')
if i%2 == 1 {
if d *= 2; d > 9 {
d -= 9
}
}
total += d
}
return fmt.Sprint((10 - total%10) % 10)
}
// generateMAC returns a locally administered unicast address.
func generateMAC(rng *rand.Rand) string {
octets := make([]string, 6)
for n := range octets {
b := rng.Intn(256)
if n == 0 {
b = (b | 0x02) &^ 0x01
}
octets[n] = fmt.Sprintf("%02x", b)
}
return strings.Join(octets, ":")
}