Add BYD vehicle telemetry exporter
Test / test (push) Failing after 25m47s

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:
thistineandClaude Opus 5 committed 2026-09-05 22:03:16 +07:00
commit 52e1aaa0c0
30 files changed
+3501

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
// Package bydtest provides a fake BYD cloud for tests.
//
// It speaks the real protocol — unwraps the bangcle envelope, checks the
// checkcode, decrypts encryData and encrypts its replies the same way — so
// tests against it exercise the whole client stack rather than a mocked-out
// transport.
//
// The server side is written independently of package byd (its own hashing and
// AES helpers, its own hard-coded endpoint paths) on purpose: if both sides
// shared a buggy helper, the bug would cancel out and the test would pass.
package bydtest
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.tinetunnel.com/thistine/byd-exporter/internal/bangcle"
)
// Endpoint paths, spelled out here rather than imported, so that a typo in the
// client's path constants shows up as an unexpected-endpoint failure.
const (
LoginPath = "/app/account/login"
VehiclesPath = "/app/account/getAllListByUserId"
RealtimeTriggerPath = "/vehicleInfo/vehicle/vehicleRealTimeRequest"
RealtimeResultPath = "/vehicleInfo/vehicle/vehicleRealTimeResult"
)
// Credentials the fake accepts. Tests configure their client to match.
const (
Password = "hunter2"
UserID = 42
SignToken = "sign-token-value"
EncryToken = "encry-token-value"
VIN = "LC0TEST0000000001"
)
// Server is a fake BYD cloud. The exported fields tune its behaviour; the
// counters record what the client did.
type Server struct {
*httptest.Server
t *testing.T
// LoginCode is the response code returned by the login endpoint ("0" is success).
LoginCode string
// ReadyAfterPolls is how many polls of the result endpoint it takes before
// a real reading appears. 1 means the first poll answers.
ReadyAfterPolls int
// Vehicles is the vehicle-list payload. Nil means one default car.
Vehicles []map[string]any
// Realtime is the reading returned once ready. Nil means a default one.
Realtime map[string]any
// Logins and Polls count requests to those endpoints.
Logins int
Polls int
}
// New starts a fake cloud with sensible defaults and registers its shutdown.
func New(t *testing.T) *Server {
t.Helper()
s := &Server{t: t, LoginCode: "0", ReadyAfterPolls: 1}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// DefaultVehicles and DefaultRealtime are what the fake returns unless a test
// overrides them.
func DefaultVehicles() []map[string]any {
return []map[string]any{{
"vin": VIN,
"modelName": "SEALION 7",
"brandName": "BYD",
"autoAlias": "Daily",
"autoPlate": "AB-1234",
"energyType": 0,
}}
}
func DefaultRealtime() map[string]any {
return map[string]any{
"requestSerial": "serial-1",
"onlineState": 1,
"elecPercent": 82,
"enduranceMileage": 391,
"totalMileage": 14203,
"leftFrontTirepressure": 240,
"vin": VIN,
}
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.t.Helper()
outer, raw, ok := s.unwrap(w, r)
if !ok {
return
}
s.verifyCheckcode(raw, outer)
switch r.URL.Path {
case LoginPath:
s.Logins++
// The login response is keyed off the password, since there is no
// session token yet.
s.reply(w, loginKey(Password), s.LoginCode, map[string]any{
"token": map[string]any{
"userId": UserID, // a number, as the cloud sends it
"signToken": SignToken,
"encryToken": EncryToken,
},
})
case VehiclesPath:
vehicles := s.Vehicles
if vehicles == nil {
vehicles = DefaultVehicles()
}
s.reply(w, contentKey(), "0", vehicles)
case RealtimeTriggerPath:
// The trigger only acknowledges; the car has to wake up first.
s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"})
case RealtimeResultPath:
s.Polls++
if s.Polls < s.ReadyAfterPolls {
s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"})
return
}
reading := s.Realtime
if reading == nil {
reading = DefaultRealtime()
}
s.reply(w, contentKey(), "0", reading)
default:
s.t.Errorf("client called an unexpected endpoint: %s", r.URL.Path)
http.Error(w, "not found", http.StatusNotFound)
}
}
// unwrap decodes the bangcle envelope and returns the outer payload both parsed
// and raw (the raw bytes are needed to verify the checkcode).
func (s *Server) unwrap(w http.ResponseWriter, r *http.Request) (map[string]any, []byte, bool) {
s.t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
s.t.Errorf("reading request body: %v", err)
return nil, nil, false
}
var wrapper struct {
Request string `json:"request"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
s.t.Errorf("request body is not JSON: %s", body)
return nil, nil, false
}
raw, err := bangcle.Decode(wrapper.Request)
if err != nil {
s.t.Errorf("client sent an undecodable envelope: %v", err)
return nil, nil, false
}
var outer map[string]any
if err := json.Unmarshal(raw, &outer); err != nil {
s.t.Errorf("outer payload is not JSON: %s", raw)
return nil, nil, false
}
return outer, raw, true
}
// verifyCheckcode recomputes the checkcode the way the cloud does. It also
// asserts that checkcode is the final field, because the hash covers exactly
// the object that precedes it — get the field order wrong and this fails.
func (s *Server) verifyCheckcode(raw []byte, outer map[string]any) {
s.t.Helper()
got, _ := outer["checkcode"].(string)
if got == "" {
s.t.Error("request carries no checkcode")
return
}
body := string(raw)
idx := strings.LastIndex(body, `,"checkcode":`)
if idx < 0 || !strings.HasSuffix(body, `"}`) {
s.t.Errorf("checkcode is not the last field: %s", body)
return
}
sum := md5.Sum([]byte(body[:idx] + "}"))
h := hex.EncodeToString(sum[:])
if want := h[24:32] + h[8:16] + h[16:24] + h[0:8]; got != want {
s.t.Errorf("checkcode = %s, want %s", got, want)
}
}
// reply encrypts respondData with key and wraps the response in an envelope.
func (s *Server) reply(w http.ResponseWriter, key, code string, respondData any) {
s.t.Helper()
body := map[string]any{"code": code, "message": "ok"}
if code == "0" {
payload, err := json.Marshal(respondData)
if err != nil {
s.t.Fatal(err)
}
body["respondData"] = encryptHex(s.t, payload, key)
}
outer, err := json.Marshal(body)
if err != nil {
s.t.Fatal(err)
}
resp, err := json.Marshal(map[string]string{"response": bangcle.Encode(outer)})
if err != nil {
s.t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
// The server-side key derivations, written out rather than imported from the
// client so the two implementations can disagree.
func md5Upper(s string) string {
sum := md5.Sum([]byte(s))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func loginKey(password string) string { return md5Upper(md5Upper(password)) }
func contentKey() string { return md5Upper(EncryToken) }
func encryptHex(t *testing.T, plaintext []byte, keyHex string) string {
t.Helper()
key, err := hex.DecodeString(keyHex)
if err != nil {
t.Fatalf("bad key %q: %v", keyHex, err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
pad := 16 - len(plaintext)%16 // PKCS#7: always 1..16 bytes
data := append([]byte{}, plaintext...)
for i := 0; i < pad; i++ {
data = append(data, byte(pad))
}
out := make([]byte, len(data))
cipher.NewCBCEncrypter(block, make([]byte, 16)).CryptBlocks(out, data)
return strings.ToUpper(hex.EncodeToString(out))
}
+267
View File
@@ -0,0 +1,267 @@
// Package byd is a read-only client for the BYD cloud API — the same endpoints
// the BYD mobile app calls. Ported from pybyd 0.0.75.
//
// Every request is wrapped twice:
//
// outer JSON -> bangcle white-box AES -> {"request": "F<base64>"}
// encryData -> AES-128-CBC (hex) with a key derived from the session token
//
// The outer envelope carries the device identity and a signature; the inner
// encryData carries the actual request fields.
//
// MQTT push is deliberately not implemented. pybyd waits for a push and falls
// back to HTTP polling on timeout; this client only polls, which costs a few
// extra requests per reading and is plenty for scraping.
package byd
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/cookiejar"
"strings"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/bangcle"
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
)
const userAgent = "okhttp/4.12.0"
// sessionExpiredCodes are the API codes that mean "log in again".
var sessionExpiredCodes = map[string]bool{"1002": true, "1005": true, "1010": true}
// Config holds everything needed to impersonate one app install.
type Config struct {
Username, Password string
BaseURL string
CountryCode string
Language string
TimeZone string
AppName string
AppVersion string
AppInnerVersion string
SoftType string
TboxVersion string
IsAuto string
SessionTTL time.Duration
PollAttempts int
PollInterval time.Duration
Device Device
}
type session struct {
userID, signToken, encryToken string
created time.Time
}
// The two AES keys for a logged-in session are derived from the tokens the
// login response carries, not from the password.
func (s *session) contentKey() string { return md5Hex(s.encryToken) }
func (s *session) signKey() string { return md5Hex(s.signToken) }
// Client is not safe for concurrent use: one scrape at a time.
type Client struct {
cfg Config
http *http.Client
sess *session
// Overridden in tests to reproduce a payload byte for byte.
now func() time.Time
nonce func() string
}
// New returns a client with a cookie jar; BYD sets session cookies the
// subsequent requests need.
func New(cfg Config) (*Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &Client{
cfg: cfg,
http: &http.Client{Jar: jar, Timeout: 30 * time.Second},
now: time.Now,
nonce: randomHex16,
}, nil
}
// APIError is a non-zero response code from an endpoint. Callers branch on Code
// to tell "session expired" from "car asleep" from a real failure.
type APIError struct {
Endpoint, Code, Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s failed: code=%s message=%s", e.Endpoint, e.Code, e.Message)
}
func randomHex16() string {
b := make([]byte, 16)
// A request nonce, not a key: math/rand is fine and avoids an error path.
rand.Read(b)
return strings.ToUpper(hex.EncodeToString(b))
}
// postSecure sends one bangcle-wrapped request and returns the decoded outer
// response. The respondData inside is still encrypted at this point.
func (c *Client) postSecure(ctx context.Context, endpoint string, outer jmap) (map[string]any, error) {
payload, err := marshalCompact(outer)
if err != nil {
return nil, err
}
body, err := json.Marshal(map[string]string{"request": bangcle.Encode(payload)})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.BaseURL+endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("accept-encoding", "identity")
req.Header.Set("content-type", "application/json; charset=UTF-8")
req.Header.Set("user-agent", userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", endpoint, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("%s: %w", endpoint, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d: %.200s", endpoint, resp.StatusCode, raw)
}
var envelope struct {
Response string `json:"response"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("%s: invalid JSON: %.200s", endpoint, raw)
}
if strings.TrimSpace(envelope.Response) == "" {
return nil, fmt.Errorf("%s: empty response payload", endpoint)
}
decoded, err := bangcle.Decode(envelope.Response)
if err != nil {
return nil, fmt.Errorf("%s: %w", endpoint, err)
}
text := strings.TrimSpace(string(decoded))
// Some responses come back with a stray F prefix on the decoded JSON.
if strings.HasPrefix(text, "F{") || strings.HasPrefix(text, "F[") {
text = text[1:]
}
var out map[string]any
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, fmt.Errorf("%s: decoded payload is not JSON: %.64s", endpoint, text)
}
return out, nil
}
// commonOuter adds the device identity fields every envelope carries.
func (c *Client) commonOuter(outer *jmap) {
d := c.cfg.Device
outer.set("ostype", d.OSType)
outer.set("imei", d.IMEI)
outer.set("mac", d.MAC)
outer.set("model", d.Model)
outer.set("sdk", d.SDK)
outer.set("mod", d.Mod)
}
// innerBase builds the fields every post-login endpoint sends.
func (c *Client) innerBase(vin, requestSerial string) jmap {
inner := jmap{}
inner.set("deviceType", c.cfg.Device.DeviceType)
inner.set("imeiMD5", c.cfg.Device.IMEIMD5)
inner.set("networkType", c.cfg.Device.NetworkType)
inner.set("random", c.nonce())
inner.set("timeStamp", fmt.Sprint(c.now().UnixMilli()))
inner.set("version", c.cfg.AppInnerVersion)
if vin != "" {
inner.set("vin", vin)
}
if requestSerial != "" {
inner.set("requestSerial", requestSerial)
}
return inner
}
// postToken signs, encrypts and sends a post-login request, returning the
// decrypted respondData as raw JSON. It may be an object or an array.
func (c *Client) postToken(ctx context.Context, endpoint string, inner jmap) (json.RawMessage, error) {
cfg := c.cfg
d := cfg.Device
s := c.sess
if s == nil {
return nil, fmt.Errorf("%s: not logged in", endpoint)
}
reqTimestamp := fmt.Sprint(c.now().UnixMilli())
contentKey := s.contentKey()
innerJSON, err := marshalCompact(inner)
if err != nil {
return nil, err
}
encryData, err := aesEncryptHex(string(innerJSON), contentKey)
if err != nil {
return nil, err
}
signFields := inner.clone()
signFields.set("countryCode", cfg.CountryCode)
signFields.set("identifier", s.userID)
signFields.set("imeiMD5", d.IMEIMD5)
signFields.set("language", cfg.Language)
signFields.set("reqTimestamp", reqTimestamp)
outer := jmap{}
outer.set("countryCode", cfg.CountryCode)
outer.set("encryData", encryData)
outer.set("identifier", s.userID)
outer.set("imeiMD5", d.IMEIMD5)
outer.set("language", cfg.Language)
outer.set("reqTimestamp", reqTimestamp)
outer.set("sign", sha1Mixed(buildSignString(signFields, s.signKey())))
c.commonOuter(&outer)
outer.set("serviceTime", fmt.Sprint(c.now().UnixMilli()))
code, err := checkcode(outer)
if err != nil {
return nil, err
}
outer.set("checkcode", code)
resp, err := c.postSecure(ctx, endpoint, outer)
if err != nil {
return nil, err
}
if rc := jsonv.Str(resp["code"]); rc != "0" {
return nil, &APIError{endpoint, rc, jsonv.Str(resp["message"])}
}
respondData := jsonv.Str(resp["respondData"])
if respondData == "" {
return json.RawMessage("{}"), nil
}
plaintext, err := aesDecryptUTF8(respondData, contentKey)
if err != nil {
return nil, fmt.Errorf("%s: %w", endpoint, err)
}
if strings.TrimSpace(plaintext) == "" {
return json.RawMessage("{}"), nil
}
if !json.Valid([]byte(plaintext)) {
return nil, fmt.Errorf("%s: respondData is not JSON: %.128s", endpoint, plaintext)
}
return json.RawMessage(plaintext), nil
}
+272
View File
@@ -0,0 +1,272 @@
package byd
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest"
)
// testDevice is the fingerprint the pybyd reference payload was generated with.
var testDevice = Device{
OSType: "and", IMEI: "356271112345674", MAC: "02:11:22:33:44:55",
Model: "SM-S928B", SDK: "35", Mod: "samsung",
IMEIMD5: "deadbeefdeadbeefdeadbeefdeadbeef",
MobileBrand: "SAMSUNG", MobileModel: "SM-S928B",
DeviceType: "0", NetworkType: "wifi", AndroidVer: "15", OSVersion: "35",
}
func testConfig(baseURL string) Config {
return Config{
Username: "[email protected]", Password: bydtest.Password,
BaseURL: baseURL,
CountryCode: "NL", Language: "en", TimeZone: "UTC",
AppName: "pyBYD+0.0.75", AppVersion: "3.5.1", AppInnerVersion: "351",
SoftType: "0", TboxVersion: "3", IsAuto: "1",
SessionTTL: time.Hour, PollAttempts: 5, PollInterval: time.Millisecond,
Device: testDevice,
}
}
// newTestClient freezes the clock and the nonce so payloads are reproducible.
func newTestClient(t *testing.T, cfg Config) *Client {
t.Helper()
c, err := New(cfg)
if err != nil {
t.Fatal(err)
}
c.now = func() time.Time { return time.UnixMilli(1700000000000) }
c.nonce = func() string { return strings.Repeat("0", 32) }
return c
}
// TestLoginRequestMatchesPybyd compares a complete login payload — field order,
// encryData, sign and checkcode — against pybyd's build_login_request run with
// the same frozen clock and nonce. This is what catches a renamed, missing or
// misordered field, which the primitive tests in crypto_test.go cannot see.
func TestLoginRequestMatchesPybyd(t *testing.T) {
const want = `{"appName":"pyBYD+0.0.75","countryCode":"NL","encryData":"2F01666A8BC0E6DBD65D2CB5EDF6AA1D9CD01D40F48A5EDE699779B21E399C5223841BD789B595E1BE40C5A0BCA07A26A0B649AB6DD3E63BC4F112D71E0EC4A904312DBF56DEBA50F407B678C1A1E8FD8AFC4F76520AD7D9EB03D9C430160C787C209DE5B38133CFBD9123E4F935E86A42EE732731F336B740BFDB206EEC7A26D756B1CF5ADF20F595532810A6019F5A717C21475DBDBA5F5415E0D4CA1AD26433761852FEE204DA496E2C775EF22C30B5EEBDCBEA164D9518F2345EC5DE5E70A6CC22B8D18B2B7BB9AEED652F2D123B2D96E5787C0EA39E20DBE529A2A4EB4EC51899D5A3CB2AEC8FF1EA1561CBA7D347C82CC075A4ECD92A48549F5492D4B4C0E487F3A45F8C7060880EF881254DBA578FAE4CBE8AA8777EFF7A26272E61867ABB33ECB752476BBB69A257CFA57C4C6E1269DA5EDFED322F13202ADC28B318C8868DFEDF3138E0A6655BB7B037480613975F5A437A698138DEA7DD26DB0B0F1445DE3D6B3C3B1B15D6DEAEFF9545EB9EE29F17A1C77BD4239ACDC00310571E1C338AE783106FDFAC488A0C6E0A4659","functionType":"pwdLogin","identifier":"[email protected]","identifierType":"0","imeiMD5":"deadbeefdeadbeefdeadbeefdeadbeef","isAuto":"1","language":"en","reqTimestamp":"1700000000000","sign":"97d29Abe36be116c9A3681703Ead98ad86c0D088","signKey":"hunter2","ostype":"and","imei":"356271112345674","mac":"02:11:22:33:44:55","model":"SM-S928B","sdk":"35","mod":"samsung","serviceTime":"1700000000000","checkcode":"8ac193b5be6e01d003b5c1da9e9036d8"}`
c := newTestClient(t, testConfig("http://example.invalid"))
outer, err := c.loginRequest()
if err != nil {
t.Fatal(err)
}
got, err := marshalCompact(outer)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Errorf("login payload differs from pybyd\n got %s\nwant %s", got, want)
}
}
func TestLoginAndVehicles(t *testing.T) {
srv := bydtest.New(t)
c := newTestClient(t, testConfig(srv.URL))
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatalf("login: %v", err)
}
// userId arrives as a JSON number; it has to survive as "42", not "4.2e+01".
if c.sess.userID != "42" {
t.Errorf("userID = %q, want 42", c.sess.userID)
}
vehicles, err := c.Vehicles(ctx)
if err != nil {
t.Fatalf("Vehicles: %v", err)
}
if len(vehicles) != 1 {
t.Fatalf("got %d vehicles, want 1", len(vehicles))
}
if got, want := vehicles[0].VIN, bydtest.VIN; got != want {
t.Errorf("VIN = %q, want %q", got, want)
}
if got, want := vehicles[0].Name(), "Daily"; got != want {
t.Errorf("Name = %q, want %q", got, want)
}
}
// TestRealtimePollsUntilReady drives the trigger-then-poll flow: the trigger
// only acknowledges, and the reading appears on a later poll.
func TestRealtimePollsUntilReady(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 3
c := newTestClient(t, testConfig(srv.URL))
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
data, err := c.Realtime(ctx, bydtest.VIN, "0")
if err != nil {
t.Fatalf("Realtime: %v", err)
}
if got := data["elecPercent"]; got != float64(82) {
t.Errorf("elecPercent = %v, want 82", got)
}
if srv.Polls != 3 {
t.Errorf("polled %d times, want 3 (stopping as soon as the car answers)", srv.Polls)
}
}
// TestRealtimeGivesUpOnSleepingCar covers the car that never answers: the
// trigger's payload comes back instead of an error, so the scrape still
// produces metrics for the rest of the fleet.
func TestRealtimeGivesUpOnSleepingCar(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 1000 // never within PollAttempts
cfg := testConfig(srv.URL)
cfg.PollAttempts = 3
c := newTestClient(t, cfg)
ctx := context.Background()
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
data, err := c.Realtime(ctx, bydtest.VIN, "0")
if err != nil {
t.Fatalf("Realtime errored on a sleeping car: %v", err)
}
if _, ok := data["requestSerial"]; !ok {
t.Errorf("expected the last payload seen, got %v", data)
}
if srv.Polls != 3 {
t.Errorf("polled %d times, want PollAttempts=3", srv.Polls)
}
}
// TestRealtimeStopsOnCancelledContext keeps a hung scrape from outliving its
// timeout: the poll loop has to watch the context, not just sleep.
func TestRealtimeStopsOnCancelledContext(t *testing.T) {
srv := bydtest.New(t)
srv.ReadyAfterPolls = 1000
cfg := testConfig(srv.URL)
cfg.PollAttempts = 1000
cfg.PollInterval = 10 * time.Millisecond
c := newTestClient(t, cfg)
if err := c.EnsureSession(context.Background()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() {
c.Realtime(ctx, bydtest.VIN, "0")
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Realtime ignored the cancelled context")
}
}
func TestLoginFailureReturnsAPIError(t *testing.T) {
srv := bydtest.New(t)
srv.LoginCode = "1003"
err := newTestClient(t, testConfig(srv.URL)).Login(context.Background())
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("got %T (%v), want *APIError", err, err)
}
if apiErr.Code != "1003" {
t.Errorf("Code = %q, want 1003", apiErr.Code)
}
if !strings.Contains(apiErr.Error(), loginEndpoint) {
t.Errorf("error does not name the endpoint: %v", apiErr)
}
}
// TestEnsureSessionReusesUntilTTL: a fresh login per scrape would be both slow
// and a good way to get the account flagged.
func TestEnsureSessionReusesUntilTTL(t *testing.T) {
srv := bydtest.New(t)
cfg := testConfig(srv.URL)
cfg.SessionTTL = time.Hour
c := newTestClient(t, cfg)
now := time.UnixMilli(1700000000000)
c.now = func() time.Time { return now }
ctx := context.Background()
for i := 0; i < 3; i++ {
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
}
if srv.Logins != 1 {
t.Errorf("logged in %d times, want 1 while inside the TTL", srv.Logins)
}
now = now.Add(2 * time.Hour)
if err := c.EnsureSession(ctx); err != nil {
t.Fatal(err)
}
if srv.Logins != 2 {
t.Errorf("logged in %d times, want a re-login once the TTL passed", srv.Logins)
}
}
func TestEndpointsRequireLogin(t *testing.T) {
c := newTestClient(t, testConfig("http://example.invalid"))
if _, err := c.Vehicles(context.Background()); err == nil {
t.Error("expected an error when calling an endpoint before logging in")
}
}
func TestRealtimeReady(t *testing.T) {
tests := []struct {
name string
info map[string]any
want bool
}{
{"empty", nil, false},
{"ack only", map[string]any{"requestSerial": "abc"}, false},
{"offline wins over data", map[string]any{"onlineState": 2.0, "enduranceMileage": 300.0}, false},
{"tyre pressure", map[string]any{"leftFrontTirepressure": 240.0}, true},
{"rear tyre pressure", map[string]any{"rightRearTirepressure": 235.0}, true},
{"timestamp", map[string]any{"time": 1.7e12}, true},
{"range", map[string]any{"enduranceMileage": 391.0}, true},
{"zero range only", map[string]any{"enduranceMileage": 0.0}, false},
{"range as a string", map[string]any{"enduranceMileage": "391"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RealtimeReady(tt.info); got != tt.want {
t.Errorf("RealtimeReady(%v) = %v, want %v", tt.info, got, tt.want)
}
})
}
}
func TestVehicleName(t *testing.T) {
tests := []struct {
v Vehicle
want string
}{
{Vehicle{AutoAlias: "Daily", AutoPlate: "AB-123", ModelName: "Sealion 7"}, "Daily"},
{Vehicle{AutoPlate: "AB-123", ModelName: "Sealion 7"}, "AB-123"},
{Vehicle{ModelName: "Sealion 7"}, "Sealion 7"},
{Vehicle{VIN: "LC0"}, "LC0"},
}
for _, tt := range tests {
if got := tt.v.Name(); got != tt.want {
t.Errorf("Name() = %q, want %q", got, tt.want)
}
}
}
+184
View File
@@ -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
}
+146
View File
@@ -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")
}
}
+126
View File
@@ -0,0 +1,126 @@
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, ":")
}
+218
View File
@@ -0,0 +1,218 @@
[
{
"mobile_brand": "SAMSUNG",
"mobile_model": "SM-S928B",
"model": "SM-S928B",
"mod": "samsung",
"tac_prefix": "35362711",
"sdk_options": ["33", "34", "35"],
"os_options": ["13", "14", "15"]
},
{
"mobile_brand": "SAMSUNG",
"mobile_model": "SM-S926B",
"model": "SM-S926B",
"mod": "samsung",
"tac_prefix": "35362611",
"sdk_options": ["33", "34", "35"],
"os_options": ["13", "14", "15"]
},
{
"mobile_brand": "SAMSUNG",
"mobile_model": "SM-S921B",
"model": "SM-S921B",
"mod": "samsung",
"tac_prefix": "35362111",
"sdk_options": ["33", "34", "35"],
"os_options": ["13", "14", "15"]
},
{
"mobile_brand": "SAMSUNG",
"mobile_model": "SM-A556B",
"model": "SM-A556B",
"mod": "samsung",
"tac_prefix": "35290412",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "SAMSUNG",
"mobile_model": "SM-A546B",
"model": "SM-A546B",
"mod": "samsung",
"tac_prefix": "35284512",
"sdk_options": ["33", "34", "35"],
"os_options": ["13", "14", "15"]
},
{
"mobile_brand": "XIAOMI",
"mobile_model": "24129PN74G",
"model": "Xiaomi 15",
"mod": "Xiaomi",
"tac_prefix": "86943604",
"sdk_options": ["35"],
"os_options": ["15"]
},
{
"mobile_brand": "XIAOMI",
"mobile_model": "2407FPN8EG",
"model": "Xiaomi 14T Pro",
"mod": "Xiaomi",
"tac_prefix": "86943504",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "XIAOMI",
"mobile_model": "23113RKC6G",
"model": "POCO X6 Pro",
"mod": "Xiaomi",
"tac_prefix": "86388105",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "XIAOMI",
"mobile_model": "2311DRK48G",
"model": "Redmi Note 13 Pro+ 5G",
"mod": "Xiaomi",
"tac_prefix": "86388205",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "ONEPLUS",
"mobile_model": "CPH2611",
"model": "CPH2611",
"mod": "OnePlus",
"tac_prefix": "86751604",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "ONEPLUS",
"mobile_model": "CPH2583",
"model": "CPH2583",
"mod": "OnePlus",
"tac_prefix": "86751504",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "ONEPLUS",
"mobile_model": "CPH2449",
"model": "CPH2449",
"mod": "OnePlus",
"tac_prefix": "86751304",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "GOOGLE",
"mobile_model": "Pixel 9 Pro",
"model": "Pixel 9 Pro",
"mod": "Google",
"tac_prefix": "35824533",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "GOOGLE",
"mobile_model": "Pixel 8",
"model": "Pixel 8",
"mod": "Google",
"tac_prefix": "35823633",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "GOOGLE",
"mobile_model": "Pixel 8a",
"model": "Pixel 8a",
"mod": "Google",
"tac_prefix": "35823733",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "OPPO",
"mobile_model": "CPH2573",
"model": "CPH2573",
"mod": "OPPO",
"tac_prefix": "86420405",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "OPPO",
"mobile_model": "CPH2591",
"model": "CPH2591",
"mod": "OPPO",
"tac_prefix": "86420505",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "MOTOROLA",
"mobile_model": "XT2351-1",
"model": "moto edge 50 pro",
"mod": "motorola",
"tac_prefix": "35982115",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "MOTOROLA",
"mobile_model": "XT2343-2",
"model": "moto g84",
"mod": "motorola",
"tac_prefix": "35982015",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "NOKIA",
"mobile_model": "TA-1643",
"model": "Nokia X30",
"mod": "HMD Global",
"tac_prefix": "35566112",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "REALME",
"mobile_model": "RMX3931",
"model": "realme GT 6",
"mod": "realme",
"tac_prefix": "86908705",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
},
{
"mobile_brand": "NOTHING",
"mobile_model": "A065",
"model": "Nothing Phone (2)",
"mod": "Nothing",
"tac_prefix": "86429904",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "HUAWEI",
"mobile_model": "ALN-AL00",
"model": "HUAWEI Pura 70 Pro",
"mod": "HUAWEI",
"tac_prefix": "86720604",
"sdk_options": ["33", "34"],
"os_options": ["13", "14"]
},
{
"mobile_brand": "SONY",
"mobile_model": "XQ-DQ72",
"model": "Xperia 1 VI",
"mod": "Sony",
"tac_prefix": "35394712",
"sdk_options": ["34", "35"],
"os_options": ["14", "15"]
}
]
+109
View File
@@ -0,0 +1,109 @@
package byd
import (
"encoding/json"
"regexp"
"strconv"
"testing"
)
// TestDeviceIsStable is the point of the whole seeded-derivation approach: a
// restart must not look like a new phone to BYD.
func TestDeviceIsStable(t *testing.T) {
a, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
b, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if a != b {
t.Errorf("same seed gave different devices:\n%+v\n%+v", a, b)
}
c, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if c.IMEI == a.IMEI {
t.Error("different seeds produced the same IMEI")
}
}
func TestDeviceLooksRealistic(t *testing.T) {
d, err := NewDevice("[email protected]")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^\d{15}$`).MatchString(d.IMEI) {
t.Errorf("IMEI %q is not 15 digits", d.IMEI)
}
if got, want := luhn(d.IMEI[:14]), string(d.IMEI[14]); got != want {
t.Errorf("IMEI %q fails the Luhn check (want check digit %s)", d.IMEI, got)
}
if !regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`).MatchString(d.MAC) {
t.Errorf("MAC %q is malformed", d.MAC)
}
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d.IMEIMD5) {
t.Errorf("IMEIMD5 %q is not a lowercase MD5", d.IMEIMD5)
}
for name, v := range map[string]string{
"Model": d.Model, "MobileBrand": d.MobileBrand, "MobileModel": d.MobileModel,
"Mod": d.Mod, "SDK": d.SDK, "AndroidVer": d.AndroidVer,
} {
if v == "" {
t.Errorf("%s is empty", name)
}
}
}
// TestDeviceMACIsLocallyAdministered keeps the generated address out of the
// space of real vendor MACs.
func TestDeviceMACIsLocallyAdministered(t *testing.T) {
for _, seed := range []string{"a", "b", "c", "d", "e"} {
d, err := NewDevice(seed)
if err != nil {
t.Fatal(err)
}
first, err := strconv.ParseInt(d.MAC[:2], 16, 16)
if err != nil {
t.Fatalf("MAC %q: %v", d.MAC, err)
}
if first&0x02 == 0 {
t.Errorf("MAC %q is not locally administered", d.MAC)
}
if first&0x01 != 0 {
t.Errorf("MAC %q is a multicast address", d.MAC)
}
}
}
func TestDevicePoolIsUsable(t *testing.T) {
var pool []poolEntry
if err := json.Unmarshal(devicePoolJSON, &pool); err != nil {
t.Fatalf("device_pool.json is not valid JSON: %v", err)
}
if len(pool) < 2 {
t.Fatalf("device pool has %d entries; a pool of one defeats the point", len(pool))
}
for i, e := range pool {
if len(e.TACPrefix) != 8 {
t.Errorf("entry %d: TAC prefix %q is not 8 digits", i, e.TACPrefix)
}
if len(e.SDKOptions) == 0 || len(e.SDKOptions) != len(e.OSOptions) {
t.Errorf("entry %d: sdk_options and os_options must pair up (%d vs %d)",
i, len(e.SDKOptions), len(e.OSOptions))
}
}
}
func TestLuhn(t *testing.T) {
// Known-good IMEIs; the 15th digit must be what luhn derives from the first 14.
for _, imei := range []string{"490154203237518", "356938035643809"} {
if got, want := luhn(imei[:14]), string(imei[14]); got != want {
t.Errorf("luhn(%s) = %s, want %s", imei[:14], got, want)
}
}
}
+271
View File
@@ -0,0 +1,271 @@
package byd
// The three endpoints a scrape needs: log in, list the cars, read one car.
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
)
const (
loginEndpoint = "/app/account/login"
vehiclesEndpoint = "/app/account/getAllListByUserId"
realtimeTrigger = "/vehicleInfo/vehicle/vehicleRealTimeRequest"
realtimeResult = "/vehicleInfo/vehicle/vehicleRealTimeResult"
)
// Vehicle is the subset of the vehicle-list entry used for metric labels.
// energyType matters beyond labelling: sending EV for a hybrid makes the cloud
// zero out the combustion-side fields.
type Vehicle struct {
VIN string `json:"vin"`
ModelName string `json:"modelName"`
BrandName string `json:"brandName"`
AutoAlias string `json:"autoAlias"`
AutoPlate string `json:"autoPlate"`
EnergyType any `json:"energyType"`
}
// Name is the friendliest label available for this car.
func (v Vehicle) Name() string {
for _, s := range []string{v.AutoAlias, v.AutoPlate, v.ModelName} {
if s != "" {
return s
}
}
return v.VIN
}
// EnsureSession logs in if there is no session or it has aged out.
func (c *Client) EnsureSession(ctx context.Context) error {
if c.sess != nil && c.now().Sub(c.sess.created) < c.cfg.SessionTTL {
return nil
}
return c.Login(ctx)
}
// Login authenticates with username and password and stores the session tokens.
func (c *Client) Login(ctx context.Context) error {
loginStart := c.now()
outer, err := c.loginRequest()
if err != nil {
return err
}
resp, err := c.postSecure(ctx, loginEndpoint, outer)
if err != nil {
return err
}
if code := jsonv.Str(resp["code"]); code != "0" {
return &APIError{loginEndpoint, code, jsonv.Str(resp["message"])}
}
respondData := jsonv.Str(resp["respondData"])
if respondData == "" {
return fmt.Errorf("%s: response missing respondData", loginEndpoint)
}
// The login response is encrypted with a key derived from the password,
// since there is no session token yet.
plaintext, err := aesDecryptUTF8(respondData, pwdLoginKey(c.cfg.Password))
if err != nil {
return fmt.Errorf("%s: %w", loginEndpoint, err)
}
var body struct {
Token struct {
UserID any `json:"userId"`
SignToken string `json:"signToken"`
EncryToken string `json:"encryToken"`
} `json:"token"`
}
if err := json.Unmarshal([]byte(plaintext), &body); err != nil {
return fmt.Errorf("%s: %w", loginEndpoint, err)
}
userID := jsonv.Str(body.Token.UserID)
if userID == "" || body.Token.SignToken == "" || body.Token.EncryToken == "" {
return fmt.Errorf("%s: response missing token fields", loginEndpoint)
}
c.sess = &session{
userID: userID,
signToken: body.Token.SignToken,
encryToken: body.Token.EncryToken,
created: loginStart,
}
return nil
}
// loginRequest builds the outer payload for the login endpoint. Split out from
// Login so a test can compare it against the reference client's output.
func (c *Client) loginRequest() (jmap, error) {
cfg := c.cfg
d := cfg.Device
reqTimestamp := fmt.Sprint(c.now().UnixMilli())
inner := jmap{}
inner.set("agreeStatus", "0")
inner.set("agreementType", "[1,2]")
inner.set("appInnerVersion", cfg.AppInnerVersion)
inner.set("appVersion", cfg.AppVersion)
inner.set("deviceName", d.MobileBrand+d.MobileModel)
inner.set("deviceType", d.DeviceType)
inner.set("imeiMD5", d.IMEIMD5)
inner.set("isAuto", cfg.IsAuto)
inner.set("mobileBrand", d.MobileBrand)
inner.set("mobileModel", d.MobileModel)
inner.set("networkType", d.NetworkType)
inner.set("osType", d.AndroidVer)
inner.set("osVersion", d.OSVersion)
inner.set("random", c.nonce())
inner.set("softType", cfg.SoftType)
inner.set("timeStamp", reqTimestamp)
inner.set("timeZone", cfg.TimeZone)
innerJSON, err := marshalCompact(inner)
if err != nil {
return nil, err
}
encryData, err := aesEncryptHex(string(innerJSON), pwdLoginKey(cfg.Password))
if err != nil {
return nil, err
}
signFields := inner.clone()
signFields.set("appName", cfg.AppName)
signFields.set("countryCode", cfg.CountryCode)
signFields.set("functionType", "pwdLogin")
signFields.set("identifier", cfg.Username)
signFields.set("identifierType", "0")
signFields.set("language", cfg.Language)
signFields.set("reqTimestamp", reqTimestamp)
outer := jmap{}
outer.set("appName", cfg.AppName)
outer.set("countryCode", cfg.CountryCode)
outer.set("encryData", encryData)
outer.set("functionType", "pwdLogin")
outer.set("identifier", cfg.Username)
outer.set("identifierType", "0")
outer.set("imeiMD5", d.IMEIMD5)
outer.set("isAuto", cfg.IsAuto)
outer.set("language", cfg.Language)
outer.set("reqTimestamp", reqTimestamp)
outer.set("sign", sha1Mixed(buildSignString(signFields, md5Hex(cfg.Password))))
outer.set("signKey", cfg.Password)
c.commonOuter(&outer)
outer.set("serviceTime", fmt.Sprint(c.now().UnixMilli()))
code, err := checkcode(outer)
if err != nil {
return nil, err
}
outer.set("checkcode", code)
return outer, nil
}
// Vehicles lists the cars on the account.
func (c *Client) Vehicles(ctx context.Context) ([]Vehicle, error) {
raw, err := c.postToken(ctx, vehiclesEndpoint, c.innerBase("", ""))
if err != nil {
return nil, err
}
var out []Vehicle
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%s: %w", vehiclesEndpoint, err)
}
return out, nil
}
// Realtime triggers a fresh reading and polls until the car answers.
//
// The trigger endpoint normally returns only a requestSerial — the car has to
// wake up and report before the result endpoint has anything to give. The app
// waits for an MQTT push; this polls the result endpoint instead.
//
// A car that never answers is not an error: the last payload seen is returned,
// so a stale-but-real reading beats no metrics at all.
func (c *Client) Realtime(ctx context.Context, vin, energyType string) (map[string]any, error) {
latest, serial, err := c.realtimeCall(ctx, realtimeTrigger, vin, energyType, "")
if err != nil {
return nil, err
}
if RealtimeReady(latest) || serial == "" {
return latest, nil
}
for attempt := 0; attempt < c.cfg.PollAttempts; attempt++ {
select {
case <-ctx.Done():
return latest, ctx.Err()
case <-time.After(c.cfg.PollInterval):
}
info, next, err := c.realtimeCall(ctx, realtimeResult, vin, energyType, serial)
if err != nil {
// Session expiry will not fix itself by polling again; anything
// else (car asleep, backend busy) might.
var apiErr *APIError
if errors.As(err, &apiErr) && sessionExpiredCodes[apiErr.Code] {
return latest, err
}
continue
}
serial = next
if len(info) > 0 {
latest = info
}
if RealtimeReady(info) {
break
}
}
return latest, nil
}
func (c *Client) realtimeCall(ctx context.Context, endpoint, vin, energyType, serial string) (map[string]any, string, error) {
inner := c.innerBase(vin, serial)
inner.set("energyType", energyType)
inner.set("tboxVersion", c.cfg.TboxVersion)
raw, err := c.postToken(ctx, endpoint, inner)
if err != nil {
return nil, serial, err
}
var info map[string]any
if err := json.Unmarshal(raw, &info); err != nil {
return nil, serial, nil // not an object; nothing usable, but not fatal
}
if s := jsonv.Str(info["requestSerial"]); s != "" {
serial = s
}
return info, serial, nil
}
// RealtimeReady reports whether a payload carries an actual reading rather than
// just an acknowledgement of the trigger.
func RealtimeReady(info map[string]any) bool {
if len(info) == 0 {
return false
}
const offline = 2
if v, ok := jsonv.Num(info["onlineState"]); ok && v == offline {
return false
}
for _, k := range []string{
"leftFrontTirepressure", "rightFrontTirepressure",
"leftRearTirepressure", "rightRearTirepressure",
} {
if v, ok := jsonv.Num(info[k]); ok && v > 0 {
return true
}
}
if v, ok := jsonv.Num(info["time"]); ok && v > 0 {
return true
}
v, ok := jsonv.Num(info["enduranceMileage"])
return ok && v > 0
}