commit 52e1aaa0c0ee436252e1ff5ac10837e62687e8e3 Author: thistine Date: Sat Sep 5 22:03:16 2026 +0700 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ceef56b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +BYD_USERNAME=you@example.com +BYD_PASSWORD=your-byd-app-password +BYD_REGION=sg +BYD_COUNTRY_CODE=TH +BYD_TIME_ZONE=Asia/Bangkok +BYD_SCHEDULE=0 * * * * diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..c79753b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + actions: read + packages: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.1 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - run: go vet ./... && go test -race ./... + + build: + needs: test + uses: infras/workflow-templates/.gitea/workflows/docker-template.yml@736cd715b4b9c80f20ec51ef8445cb630c8299f5 + with: + image_name: byd-exporter + image_owner: thistine + image_tag: ${{ gitea.ref_name }} + secrets: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..24e0f5f --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.1 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: gofmt + run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + - run: go vet ./... + - run: go test -race -cover ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d3f454 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/byd-exporter +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ecda13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /byd-exporter . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /byd-exporter /byd-exporter +EXPOSE 9838 +ENTRYPOINT ["/byd-exporter"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..677e216 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# byd-exporter + +Scrapes vehicle telemetry from the BYD cloud — the same API the BYD mobile app +talks to — on a cron schedule, and serves the last reading as Prometheus metrics. + +Go port of the protocol layer of [pybyd](https://pypi.org/project/pybyd/) 0.0.75, +the client behind [jkaberg/hass-byd-vehicle](https://github.com/jkaberg/hass-byd-vehicle). + +## Why cron and not scrape-on-request + +Every reading wakes the car's T-Box over the mobile network. The Home Assistant +integration measures roughly **0.1 kWh/h of traction-battery drain at a 5-minute +poll interval** on a 60 kWh pack, and defaults to 5 minutes for that reason. + +Letting Prometheus scrape the car directly would poll it every 15 seconds. So the +schedule drives the BYD calls, `/metrics` just serves the last result, and +Prometheus can scrape as often as it likes for free. Default schedule is hourly. + +## Run + +```bash +export BYD_USERNAME='you@example.com' BYD_PASSWORD='...' +go run ./cmd/byd-exporter -once # one scrape, print metrics, exit +go run ./cmd/byd-exporter # serve :9838/metrics on the cron schedule +``` + +## Configuration + +All via environment variables. + +| Variable | Default | Notes | +|---|---|---| +| `BYD_USERNAME` | — | **required.** BYD app account (email or phone) | +| `BYD_PASSWORD` | — | **required.** BYD app password | +| `BYD_SCHEDULE` | `0 * * * *` | 5-field cron expression, or `@every 30m` | +| `BYD_LISTEN` | `:9838` | metrics listen address | +| `BYD_REGION` | `eu` | region code in `dilinkappoversea-.byd.auto` — `sg` for APAC/Thailand, `eu`, `au`, `br`, `jp`, `in`, `id`, `tr`, `kr`, `vn`, `mx`, `sa`, `om`, `kz`, `uz`, `no` (Middle East/Africa) | +| `BYD_BASE_URL` | derived from region | full override | +| `BYD_COUNTRY_CODE` | `NL` | ISO country of the account | +| `BYD_LANGUAGE` | `en` | | +| `BYD_TIME_ZONE` | `UTC` | IANA zone sent to the API | +| `BYD_POLL_ATTEMPTS` | `10` | result polls before giving up on a sleeping car | +| `BYD_POLL_INTERVAL` | `3s` | delay between those polls | +| `BYD_SESSION_TTL` | `12h` | re-login interval | +| `BYD_DEVICE_SEED` | the username | see below | + +### Device identity + +BYD tracks the phone that logs in. The exporter derives a stable, realistic +Android fingerprint (IMEI with a valid TAC prefix and Luhn digit, MAC, model) +deterministically from `BYD_DEVICE_SEED`, so it looks like the same phone across +restarts without persisting any state. Change the seed only if you want to +present as a different device. + +Running this alongside the Home Assistant integration on the same account means +two "phones" polling one car. That works, but doubles the wake-ups. + +## Metrics + +Every numeric field of the realtime payload is exported as +`byd_{vin,name}`. There is no hand-maintained field list — BYD +changes the payload between firmware versions, and new fields appear on their own. + +``` +byd_elec_percent{vin="LC0...",name="Sealion 7"} 82 +byd_endurance_mileage{vin="LC0...",name="Sealion 7"} 391 +byd_total_mileage{vin="LC0...",name="Sealion 7"} 14203 +byd_left_front_tirepressure{vin="LC0...",name="Sealion 7"} 240 +byd_vehicle_up{vin="LC0...",name="Sealion 7"} 1 +byd_vehicle_info{vin="LC0...",name="Sealion 7",model="...",brand="BYD",plate="..."} 1 +byd_scrape_success 1 +byd_scrape_duration_seconds 21.4 +byd_scrape_timestamp_seconds 1757000000 +``` + +The trade-off: enum fields (doors, gear, charge state) arrive as their raw +integers. Decode them in the dashboard — the meanings are in +[pybyd's realtime model](https://pypi.org/project/pybyd/) and +`custom_components/byd_vehicle/sensor.py` upstream. + +`byd_scrape_success 0` means the whole scrape failed (login or vehicle list); +`byd_vehicle_up 0` means one car didn't answer while the rest did. + +## Prometheus + +Add to the agent's `scrape_configs`. The scrape interval is free — it reads a +cached value, it does not touch the car. + +```yaml + - job_name: 'byd' + static_configs: + - targets: ['byd-exporter:9838'] +``` + +## Deploy + +Tag push builds and pushes the image via +[`infras/workflow-templates`](https://git.tinetunnel.com/infras/workflow-templates): + +```bash +git tag v0.1.0 && git push origin v0.1.0 +``` + +Requires `REGISTRY_USER` and `REGISTRY_TOKEN` as **repository** secrets — the +org-level secrets in `infras` do not reach a repo in the `thistine` namespace. + +Then copy `docker-compose.yml` into `infras/sg1-docker-composes` as +`byd-exporter/docker-compose.yml`, and add a `deploy` job to +`.gitea/workflows/release.yml` calling `bump-compose-version.yml` to roll out +each tag. + +## What this deliberately does not do + +- **No MQTT.** pybyd listens for a push and falls back to HTTP polling on + timeout. This only polls — a scrape costs a handful of extra requests and + takes a few seconds longer. +- **No GPS.** `/control/getGpsInfo` is a second trigger-and-poll that wakes the + car again. Add it if you want location badly enough to pay for it. +- **No remote control.** Read-only by design; no lock, climate, or charge commands. +- **No typed model.** The 1000-line pydantic model upstream is replaced by + "export anything numeric". + +## Layout + +``` +cmd/byd-exporter/ flags, environment config, cron, HTTP server +internal/bangcle/ white-box AES envelope codec + the extracted tables +internal/byd/ API client: crypto, device fingerprint, endpoints +internal/byd/bydtest/ fake BYD cloud that speaks the real protocol +internal/metrics/ Prometheus text exposition +internal/exporter/ scrape orchestration and the /metrics handler +testdata/ fixture generator (Python, reads the reference client) +``` + +`bangcle` and `jsonv` are leaves; `byd` builds on them, `exporter` builds on +`byd` and `metrics`, and `cmd` only wires things together. + +## Development + +```bash +go test -race -cover ./... +``` + +Coverage sits around 90%. Three layers of test: + +- **Wire-format fixtures.** The bangcle ciphertext, the signing primitives and a + complete login payload are compared byte-for-byte against pybyd's output for + the same frozen clock and nonce. A renamed or reordered field fails here. +- **A fake cloud** (`internal/byd/bydtest`) that unwraps the envelope, verifies + the checkcode, decrypts `encryData` and encrypts its replies the same way the + real backend does. Its server side is written independently of the client, so + a shared bug cannot cancel itself out. Login, vehicle list, trigger-then-poll + and the sleeping-car path all run through it end to end. +- **Unit tests** for the payload-to-metric mapping and the JSON coercion, where + BYD's habit of quoting numbers actually bites. + +If BYD rotates the crypto tables, regenerate the fixtures with +`testdata/gen_fixtures.py` and replace `internal/bangcle/tables.bin` from the +new pybyd wheel. + +## Credit + +Protocol reverse engineering is entirely [@jkaberg](https://github.com/jkaberg)'s +in `hass-byd-vehicle` / `pybyd`. This repo is a Go transcription of the read path. diff --git a/cmd/byd-exporter/main.go b/cmd/byd-exporter/main.go new file mode 100644 index 0000000..d0f7316 --- /dev/null +++ b/cmd/byd-exporter/main.go @@ -0,0 +1,134 @@ +// Command byd-exporter scrapes vehicle telemetry from the BYD cloud on a cron +// schedule and serves it as Prometheus metrics. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/robfig/cron/v3" + + "git.tinetunnel.com/thistine/byd-exporter/internal/byd" + "git.tinetunnel.com/thistine/byd-exporter/internal/exporter" +) + +// scrapeTimeout bounds one scrape. A sleeping car is polled for a while before +// giving up, so this is generous relative to the work. +const scrapeTimeout = 5 * time.Minute + +func main() { + once := flag.Bool("once", false, "scrape once, print metrics to stdout and exit") + flag.Parse() + + cfg, err := configFromEnv() + if err != nil { + log.Fatal(err) + } + + client, err := byd.New(cfg) + if err != nil { + log.Fatal(err) + } + exp := exporter.New(client, log.Default()) + + if *once { + err := scrape(exp) + fmt.Print(exp.Metrics()) + if err != nil { + os.Exit(1) + } + return + } + + schedule := env("BYD_SCHEDULE", "0 * * * *") + parsed, err := cron.ParseStandard(schedule) + if err != nil { + log.Fatalf("BYD_SCHEDULE %q: %v", schedule, err) + } + + // Fill /metrics before the first tick, so a restart does not serve an empty + // body until the next scheduled run. + go scrape(exp) + + runner := cron.New() + runner.Schedule(parsed, cron.FuncJob(func() { scrape(exp) })) + runner.Start() + defer runner.Stop() + + listen := env("BYD_LISTEN", ":9838") + mux := http.NewServeMux() + mux.Handle("/metrics", exp) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + }) + + log.Printf("byd-exporter listening on %s, schedule %q, endpoint %s", listen, schedule, cfg.BaseURL) + log.Fatal(http.ListenAndServe(listen, mux)) +} + +func scrape(exp *exporter.Exporter) error { + ctx, cancel := context.WithTimeout(context.Background(), scrapeTimeout) + defer cancel() + return exp.Scrape(ctx) +} + +func configFromEnv() (byd.Config, error) { + username, password := os.Getenv("BYD_USERNAME"), os.Getenv("BYD_PASSWORD") + if username == "" || password == "" { + return byd.Config{}, fmt.Errorf("BYD_USERNAME and BYD_PASSWORD are required") + } + + // The device identity is derived from a seed rather than stored, so it + // survives restarts without a volume. See byd.NewDevice. + device, err := byd.NewDevice(env("BYD_DEVICE_SEED", username)) + if err != nil { + return byd.Config{}, err + } + + region := env("BYD_REGION", "eu") + return byd.Config{ + Username: username, + Password: password, + BaseURL: env("BYD_BASE_URL", "https://dilinkappoversea-"+region+".byd.auto"), + CountryCode: env("BYD_COUNTRY_CODE", "NL"), + Language: env("BYD_LANGUAGE", "en"), + TimeZone: env("BYD_TIME_ZONE", "UTC"), + AppName: env("BYD_APP_NAME", "pyBYD+0.0.75"), + AppVersion: env("BYD_APP_VERSION", "3.5.1"), + AppInnerVersion: env("BYD_APP_INNER_VERSION", "351"), + SoftType: "0", + TboxVersion: env("BYD_TBOX_VERSION", "3"), + IsAuto: "1", + SessionTTL: envDuration("BYD_SESSION_TTL", 12*time.Hour), + PollAttempts: envInt("BYD_POLL_ATTEMPTS", 10), + PollInterval: envDuration("BYD_POLL_INTERVAL", 3*time.Second), + Device: device, + }, nil +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envInt(key string, fallback int) int { + if v, err := strconv.Atoi(os.Getenv(key)); err == nil { + return v + } + return fallback +} + +func envDuration(key string, fallback time.Duration) time.Duration { + if v, err := time.ParseDuration(os.Getenv(key)); err == nil { + return v + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79e2f36 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# Deployment stack. Copy to the GitOps compose repo as byd-exporter/docker-compose.yml. +services: + byd-exporter: + image: 192.168.1.17:30008/thistine/byd-exporter:latest + container_name: byd-exporter + restart: always + environment: + BYD_USERNAME: ${BYD_USERNAME:?set in .env} + BYD_PASSWORD: ${BYD_PASSWORD:?set in .env} + BYD_REGION: ${BYD_REGION:-sg} + BYD_COUNTRY_CODE: ${BYD_COUNTRY_CODE:-TH} + BYD_TIME_ZONE: ${BYD_TIME_ZONE:-Asia/Bangkok} + # Every scrape wakes the car. Hourly is already fairly aggressive. + BYD_SCHEDULE: ${BYD_SCHEDULE:-0 * * * *} + networks: + - proxy + +networks: + proxy: + external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..83f64f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.tinetunnel.com/thistine/byd-exporter + +go 1.21 + +require github.com/robfig/cron/v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0667807 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= diff --git a/internal/bangcle/bangcle.go b/internal/bangcle/bangcle.go new file mode 100644 index 0000000..7837174 --- /dev/null +++ b/internal/bangcle/bangcle.go @@ -0,0 +1,174 @@ +// Package bangcle implements the "Bangcle" envelope every BYD API request and +// response is wrapped in: white-box AES-CBC, base64, with an "F" prefix. +// +// The cipher has no key schedule. Its round functions are pre-computed lookup +// tables extracted from the app's libencrypt.so, shipped here as tables.bin and +// embedded in the binary. Ported from pybyd's _crypto/_bangcle_block.py. +package bangcle + +import ( + _ "embed" + "encoding/base64" + "encoding/binary" + "fmt" + "strings" +) + +//go:embed tables.bin +var tablesBin []byte + +// tables is built at init because tables.bin is compiled into the binary: if it +// fails to parse the build is broken, and there is no runtime recovery. +var tables = mustLoadTables(tablesBin) + +// Encode wraps a payload as "F" + base64(whitebox-AES-CBC(pkcs7(plain))). +func Encode(plain []byte) string { + return "F" + base64.StdEncoding.EncodeToString(encryptCBC(tables, padPKCS7(plain))) +} + +// Decode unwraps an envelope produced by the BYD API. +func Decode(envelope string) ([]byte, error) { + s := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "-", "+", "_", "/").Replace(envelope) + if s == "" { + return nil, fmt.Errorf("bangcle: empty input") + } + if !strings.HasPrefix(s, "F") { + return nil, fmt.Errorf("bangcle: envelope must start with %q", "F") + } + s = s[1:] + if r := len(s) % 4; r != 0 { + s += strings.Repeat("=", 4-r) + } + ct, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("bangcle: invalid base64: %w", err) + } + if len(ct) == 0 || len(ct)%16 != 0 { + return nil, fmt.Errorf("bangcle: ciphertext length %d is not a positive multiple of 16", len(ct)) + } + return StripPKCS7(decryptCBC(tables, ct)), nil +} + +// PadPKCS7 and StripPKCS7 are exported because the inner AES-CBC layer in +// package byd uses the same padding rules. +func PadPKCS7(data []byte) []byte { return padPKCS7(data) } + +func padPKCS7(data []byte) []byte { + n := 16 - len(data)%16 // 1..16, so an aligned input gets a whole extra block + pad := make([]byte, n) + for i := range pad { + pad[i] = byte(n) + } + return append(data, pad...) +} + +// StripPKCS7 returns data unchanged when the padding is not well formed. BYD +// occasionally returns unpadded payloads, and pybyd tolerates them too. +func StripPKCS7(data []byte) []byte { + if len(data) == 0 { + return data + } + n := int(data[len(data)-1]) + if n == 0 || n > 16 || n > len(data) { + return data + } + for _, b := range data[len(data)-n:] { + if int(b) != n { + return data + } + } + return data[:len(data)-n] +} + +// encryptCBC and decryptCBC use a zero IV, matching the app. + +func encryptCBC(t *lookupTables, data []byte) []byte { + res := make([]byte, len(data)) + prev := make([]byte, 16) + buf := make([]byte, 16) + for off := 0; off < len(data); off += 16 { + for i := 0; i < 16; i++ { + buf[i] = data[off+i] ^ prev[i] + } + enc := encryptBlock(t, buf) + copy(res[off:], enc) + copy(prev, enc) + } + return res +} + +func decryptCBC(t *lookupTables, data []byte) []byte { + res := make([]byte, len(data)) + prev := make([]byte, 16) + for off := 0; off < len(data); off += 16 { + block := data[off : off+16] + dec := decryptBlock(t, block) + for i := 0; i < 16; i++ { + res[off+i] = dec[i] ^ prev[i] + } + copy(prev, block) + } + return res +} + +type lookupTables struct { + invRound, invXor, invFirst []byte + round, xor, final []byte + permDecrypt, permEncrypt []byte +} + +var tableSpecs = []struct { + name string + size int +}{ + {"inv_round", 0x28000}, + {"inv_xor", 0x3C000}, + {"inv_first", 0x1000}, + {"round", 0x28000}, + {"xor", 0x3C000}, + {"final", 0x1000}, + {"perm_decrypt", 8}, + {"perm_encrypt", 8}, +} + +func mustLoadTables(data []byte) *lookupTables { + t, err := loadTables(data) + if err != nil { + panic("bangcle: " + err.Error()) + } + return t +} + +// loadTables parses the BGTB container: magic, uint16 version, uint16 count, +// then count (offset, length) uint32 pairs, then the raw table bytes. +func loadTables(data []byte) (*lookupTables, error) { + const header, indexEntry, count = 8, 8, 8 + if len(data) < header+count*indexEntry { + return nil, fmt.Errorf("table file too short (%d bytes)", len(data)) + } + if string(data[:4]) != "BGTB" { + return nil, fmt.Errorf("bad magic %q", data[:4]) + } + if v := binary.LittleEndian.Uint16(data[4:]); v != 1 { + return nil, fmt.Errorf("unsupported table version %d", v) + } + if n := binary.LittleEndian.Uint16(data[6:]); n != count { + return nil, fmt.Errorf("expected %d tables, got %d", count, n) + } + + out := make([][]byte, count) + for i := 0; i < count; i++ { + p := header + i*indexEntry + off := binary.LittleEndian.Uint32(data[p:]) + length := binary.LittleEndian.Uint32(data[p+4:]) + spec := tableSpecs[i] + if int(length) != spec.size { + return nil, fmt.Errorf("table %s: expected %d bytes, got %d", spec.name, spec.size, length) + } + if int(off)+int(length) > len(data) { + return nil, fmt.Errorf("table %s: extends beyond file", spec.name) + } + out[i] = data[off : off+length] + } + return &lookupTables{out[0], out[1], out[2], out[3], out[4], out[5], out[6], out[7]}, nil +} diff --git a/internal/bangcle/bangcle_test.go b/internal/bangcle/bangcle_test.go new file mode 100644 index 0000000..ad3e3fc --- /dev/null +++ b/internal/bangcle/bangcle_test.go @@ -0,0 +1,155 @@ +package bangcle + +// Expected values come from pybyd 0.0.75 — regenerate with testdata/gen_fixtures.py. + +import ( + "strings" + "testing" +) + +func TestRoundTrip(t *testing.T) { + inputs := []string{ + "", + "a", + "0123456789abcdef", // exactly one block: must gain a whole pad block + strings.Repeat("x", 4096), + `{"request":"hello world"}`, + "ünïcodé ✓", + } + for _, in := range inputs { + out, err := Decode(Encode([]byte(in))) + if err != nil { + t.Fatalf("Decode(Encode(%.20q)): %v", in, err) + } + if string(out) != in { + t.Errorf("round trip %.20q -> %.20q", in, out) + } + } +} + +// TestVectors pins the white-box AES port against ciphertext from pybyd. If the +// block functions drift by a single index this fails; round-tripping alone +// would not, since it would drift symmetrically. +func TestVectors(t *testing.T) { + vectors := []struct{ plain, want string }{ + {"", "FlpybH7xiohrkjagBOJY5Mg=="}, + {"hello", "FsFbBBn9/qw4cYEF9S1Jk6w=="}, + {"ünïcodé ✓", "FRqewj1Ypq1V2n8VqsiZPUA=="}, + {`{"functionType":"pwdLogin"}`, "FCHhJ73+HviiIT3dTDBeITnzHp5caNe/C50GlE8P0dqQ="}, + } + for _, v := range vectors { + if got := Encode([]byte(v.plain)); got != v.want { + t.Errorf("Encode(%q)\n got %s\nwant %s", v.plain, got, v.want) + } + } +} + +func TestDecodeRejectsMalformed(t *testing.T) { + cases := map[string]string{ + "empty": "", + "missing prefix": "aGVsbG8=", + "bad base64": "F!!!!", + "short block": "F" + "AAAA", // 3 bytes: not a multiple of 16 + } + for name, in := range cases { + if _, err := Decode(in); err == nil { + t.Errorf("%s: expected an error for %q", name, in) + } + } +} + +// TestDecodeNormalises covers the url-safe alphabet, stray whitespace and the +// missing padding that BYD responses sometimes arrive with. +func TestDecodeNormalises(t *testing.T) { + want := "the quick brown fox" + env := Encode([]byte(want)) + + variants := []string{ + env, + strings.TrimRight(env, "="), + strings.NewReplacer("+", "-", "/", "_").Replace(env), + " " + env + "\n", + } + for _, v := range variants { + got, err := Decode(v) + if err != nil { + t.Errorf("Decode(%q): %v", v, err) + continue + } + if string(got) != want { + t.Errorf("Decode(%q) = %q, want %q", v, got, want) + } + } +} + +func TestStripPKCS7LeavesUnpaddedData(t *testing.T) { + // BYD sometimes returns payloads without padding; pybyd passes them + // through rather than erroring, and so must this. + for _, in := range []string{"", "no padding here", "\x05\x05\x05"} { + if got := StripPKCS7([]byte(in)); string(got) != in { + t.Errorf("StripPKCS7(%q) = %q, want it unchanged", in, got) + } + } + padded := append([]byte("abc"), 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13) + if got := StripPKCS7(padded); string(got) != "abc" { + t.Errorf("StripPKCS7(valid) = %q, want %q", got, "abc") + } +} + +func TestPadPKCS7AlwaysAddsAtLeastOneByte(t *testing.T) { + for _, n := range []int{0, 1, 15, 16, 17, 32} { + got := PadPKCS7(make([]byte, n)) + if len(got)%16 != 0 || len(got) <= n { + t.Errorf("PadPKCS7(%d bytes) produced %d bytes", n, len(got)) + } + } +} + +func TestTablesLoaded(t *testing.T) { + for _, spec := range tableSpecs { + var got int + switch spec.name { + case "inv_round": + got = len(tables.invRound) + case "inv_xor": + got = len(tables.invXor) + case "inv_first": + got = len(tables.invFirst) + case "round": + got = len(tables.round) + case "xor": + got = len(tables.xor) + case "final": + got = len(tables.final) + case "perm_decrypt": + got = len(tables.permDecrypt) + case "perm_encrypt": + got = len(tables.permEncrypt) + } + if got != spec.size { + t.Errorf("table %s: %d bytes, want %d", spec.name, got, spec.size) + } + } +} + +func TestLoadTablesRejectsBadContainer(t *testing.T) { + cases := map[string][]byte{ + "too short": []byte("BGTB"), + "bad magic": append([]byte("XXXX"), make([]byte, 100)...), + "bad version": append([]byte("BGTB\x02\x00\x08\x00"), make([]byte, 100)...), + "bad count": append([]byte("BGTB\x01\x00\x03\x00"), make([]byte, 100)...), + } + for name, data := range cases { + if _, err := loadTables(data); err == nil { + t.Errorf("%s: expected an error", name) + } + } +} + +func BenchmarkEncode(b *testing.B) { + payload := []byte(strings.Repeat(`{"field":"value"},`, 100)) + b.SetBytes(int64(len(payload))) + for i := 0; i < b.N; i++ { + Encode(payload) + } +} diff --git a/internal/bangcle/block.go b/internal/bangcle/block.go new file mode 100644 index 0000000..63e504c --- /dev/null +++ b/internal/bangcle/block.go @@ -0,0 +1,165 @@ +package bangcle + +// White-box AES block functions. +// +// The index arithmetic below is a one-for-one transcription of decompiled code +// (via pybyd). It is deliberately not tidied or renamed: keeping it diffable +// against the reference is worth more than readable variable names, because the +// only way to debug a mistake here is to compare intermediate state with pybyd. + +import "encoding/binary" + +// prepareMatrix transposes a 4x4 block into the working state layout (col*8+row). +func prepareMatrix(in, state []byte) { + for col := 0; col < 4; col++ { + for row := 0; row < 4; row++ { + state[col*8+row] = in[col+row*4] + } + } +} + +func decryptBlock(t *lookupTables, block []byte) []byte { + state := make([]byte, 32) + temp64 := make([]byte, 64) + tmp32 := make([]byte, 32) + out := make([]byte, 16) + + prepareMatrix(block, state) + + for rnd := 9; rnd >= 1; rnd-- { + l21 := rnd * 4 + permPtr := 0 + for i := 0; i < 4; i++ { + b := int(t.permDecrypt[permPtr]) + l16 := i * 8 + base := i * 16 + for j := 0; j < 4; j++ { + u7 := (b + j) & 3 + idx := int(state[l16+u7]) + (i+(l21+u7)*4)*256 + binary.LittleEndian.PutUint32(temp64[base+j*4:], binary.LittleEndian.Uint32(t.invRound[idx*4:])) + } + permPtr += 2 + } + + i15 := 1 + for l21x := 0; l21x < 4; l21x++ { + pb := l21x + for l9 := 0; l9 < 4; l9++ { + u6 := int(temp64[pb]) & 0xF + u26 := int(temp64[pb]) & 0xF0 + f := [3]int{int(temp64[pb+0x10]), int(temp64[pb+0x20]), int(temp64[pb+0x30])} + + l2 := l9*0x18 + rnd*0x60 + i25 := i15 + for k := 0; k < 3; k++ { + b := f[k] + u27 := u6 | ((b << 4) & 0xFF) + u26 = ((u26 >> 4) | ((b >> 4) << 4)) & 0xFF + u6 = int(t.invXor[(l2+i25-1)*0x100+u27]) & 0xF + u26 = (int(t.invXor[(l2+i25)*0x100+u26]) & 0xF) << 4 + i25 += 2 + } + state[l9+l21x*8] = byte((u26 | u6) & 0xFF) + pb += 4 + } + i15 += 6 + } + } + + copy(tmp32, state) + u8, u10, u12 := 1, 3, 2 + for row := 0; row < 4; row++ { + state[row] = t.invFirst[int(tmp32[row])+row*0x400] + + r1 := u10 & 3 + state[8+row] = t.invFirst[int(tmp32[8+r1])+r1*0x400+0x100] + + r2 := u12 & 3 + state[0x10+row] = t.invFirst[int(tmp32[0x10+r2])+r2*0x400+0x200] + + r3 := u8 & 3 + state[0x18+row] = t.invFirst[int(tmp32[0x18+r3])+r3*0x400+0x300] + + u8++ + u10++ + u12++ + } + + for col := 0; col < 4; col++ { + for row := 0; row < 4; row++ { + out[col+row*4] = state[col*8+row] + } + } + return out +} + +func encryptBlock(t *lookupTables, block []byte) []byte { + state := make([]byte, 32) + temp64 := make([]byte, 64) + tmp32 := make([]byte, 32) + out := make([]byte, 16) + + prepareMatrix(block, state) + + for rnd := 0; rnd < 9; rnd++ { + l21 := rnd * 4 + permPtr := 0 + for i := 0; i < 4; i++ { + b := int(t.permEncrypt[permPtr]) + l16 := i * 8 + base := i * 16 + for j := 0; j < 4; j++ { + u8 := (b + j) & 3 + idx := int(state[l16+u8]) + (i+(l21+u8)*4)*256 + binary.LittleEndian.PutUint32(temp64[base+j*4:], binary.LittleEndian.Uint32(t.round[idx*4:])) + } + permPtr += 2 + } + + i16 := 1 + for l22 := 0; l22 < 4; l22++ { + pb := l22 + for l10 := 0; l10 < 4; l10++ { + u7 := int(temp64[pb]) & 0xF + u26 := int(temp64[pb]) & 0xF0 + f := [3]int{int(temp64[pb+0x10]), int(temp64[pb+0x20]), int(temp64[pb+0x30])} + + l2 := l10*0x18 + rnd*0x60 + i25 := i16 + for k := 0; k < 3; k++ { + b := f[k] + u27 := u7 | ((b << 4) & 0xFF) + u26 = ((u26 >> 4) | ((b >> 4) << 4)) & 0xFF + u7 = int(t.xor[(l2+i25-1)*0x100+u27]) & 0xF + u26 = (int(t.xor[(l2+i25)*0x100+u26]) & 0xF) << 4 + i25 += 2 + } + state[l10+l22*8] = byte((u26 | u7) & 0xFF) + pb += 4 + } + i16 += 6 + } + } + + copy(tmp32, state) + for row := 0; row < 4; row++ { + r0 := row & 3 + state[row] = t.final[int(tmp32[r0])+r0*0x400] + + r1 := (1 + row) & 3 + state[8+row] = t.final[int(tmp32[8+r1])+r1*0x400+0x100] + + r2 := (2 + row) & 3 + state[0x10+row] = t.final[int(tmp32[0x10+r2])+r2*0x400+0x200] + + r3 := (3 + row) & 3 + state[0x18+row] = t.final[int(tmp32[0x18+r3])+r3*0x400+0x300] + } + + for col := 0; col < 4; col++ { + for row := 0; row < 4; row++ { + out[col+row*4] = state[col*8+row] + } + } + return out +} diff --git a/internal/bangcle/tables.bin b/internal/bangcle/tables.bin new file mode 100644 index 0000000..fc72997 Binary files /dev/null and b/internal/bangcle/tables.bin differ diff --git a/internal/byd/bydtest/server.go b/internal/byd/bydtest/server.go new file mode 100644 index 0000000..ced030d --- /dev/null +++ b/internal/byd/bydtest/server.go @@ -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)) +} diff --git a/internal/byd/client.go b/internal/byd/client.go new file mode 100644 index 0000000..570d9a1 --- /dev/null +++ b/internal/byd/client.go @@ -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"} +// 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 +} diff --git a/internal/byd/client_test.go b/internal/byd/client_test.go new file mode 100644 index 0000000..0c058e8 --- /dev/null +++ b/internal/byd/client_test.go @@ -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: "user@example.com", 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":"user@example.com","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) + } + } +} diff --git a/internal/byd/crypto.go b/internal/byd/crypto.go new file mode 100644 index 0000000..d505b7c --- /dev/null +++ b/internal/byd/crypto.go @@ -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 +} diff --git a/internal/byd/crypto_test.go b/internal/byd/crypto_test.go new file mode 100644 index 0000000..eca7526 --- /dev/null +++ b/internal/byd/crypto_test.go @@ -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", "ad") + b, err := marshalCompact(m) + if err != nil { + t.Fatal(err) + } + if want := `{"q":"ad"}`; 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") + } +} diff --git a/internal/byd/device.go b/internal/byd/device.go new file mode 100644 index 0000000..f8516a7 --- /dev/null +++ b/internal/byd/device.go @@ -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, ":") +} diff --git a/internal/byd/device_pool.json b/internal/byd/device_pool.json new file mode 100644 index 0000000..ec917eb --- /dev/null +++ b/internal/byd/device_pool.json @@ -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"] + } +] diff --git a/internal/byd/device_test.go b/internal/byd/device_test.go new file mode 100644 index 0000000..69660e7 --- /dev/null +++ b/internal/byd/device_test.go @@ -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("user@example.com") + if err != nil { + t.Fatal(err) + } + b, err := NewDevice("user@example.com") + 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("other@example.com") + 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("user@example.com") + 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) + } + } +} diff --git a/internal/byd/endpoints.go b/internal/byd/endpoints.go new file mode 100644 index 0000000..b97364c --- /dev/null +++ b/internal/byd/endpoints.go @@ -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 +} diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go new file mode 100644 index 0000000..c393703 --- /dev/null +++ b/internal/exporter/exporter.go @@ -0,0 +1,125 @@ +// Package exporter runs the scrape on a schedule and serves the last result. +// +// Scraping is scheduled rather than done per Prometheus request on purpose: +// each reading wakes the car's T-Box over the mobile network and drains the +// traction battery (roughly 0.1 kWh/h at a 5-minute interval on a 60 kWh pack, +// per the Home Assistant integration). Prometheus can scrape /metrics as often +// as it likes; only the schedule reaches the car. +package exporter + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + "time" + + "git.tinetunnel.com/thistine/byd-exporter/internal/byd" + "git.tinetunnel.com/thistine/byd-exporter/internal/jsonv" + "git.tinetunnel.com/thistine/byd-exporter/internal/metrics" +) + +// MetricPrefix is prepended to every field name from the realtime payload. +const MetricPrefix = "byd_" + +// Exporter holds the last scrape so /metrics can serve it without touching the car. +type Exporter struct { + client *byd.Client + log *log.Logger + + mu sync.RWMutex + latest string +} + +func New(client *byd.Client, logger *log.Logger) *Exporter { + if logger == nil { + logger = log.Default() + } + return &Exporter{client: client, log: logger} +} + +// Scrape reads every vehicle and replaces the served metrics. It always +// publishes something — on failure, byd_scrape_success 0 — so that a broken +// scrape is visible in Prometheus rather than silently serving stale data. +func (e *Exporter) Scrape(ctx context.Context) error { + start := time.Now() + set := metrics.New() + + err := e.collect(ctx, set) + if err != nil { + e.log.Printf("scrape failed: %v", err) + } + + success := 0.0 + if err == nil { + success = 1 + } + set.Add("byd_scrape_success", nil, success) + set.Add("byd_scrape_duration_seconds", nil, time.Since(start).Seconds()) + set.Add("byd_scrape_timestamp_seconds", nil, float64(start.Unix())) + + e.mu.Lock() + e.latest = set.String() + e.mu.Unlock() + return err +} + +// collect fills set with one reading per vehicle. A single unreachable car is +// reported as byd_vehicle_up 0 and does not stop the others. +func (e *Exporter) collect(ctx context.Context, set *metrics.Set) error { + if err := e.client.EnsureSession(ctx); err != nil { + return fmt.Errorf("login: %w", err) + } + + vehicles, err := e.client.Vehicles(ctx) + if err != nil { + return fmt.Errorf("vehicle list: %w", err) + } + set.Add("byd_vehicles", nil, float64(len(vehicles))) + + var firstErr error + for _, v := range vehicles { + if v.VIN == "" { + continue + } + labels := metrics.Labels{"vin": v.VIN, "name": v.Name()} + set.Add("byd_vehicle_info", metrics.Labels{ + "vin": v.VIN, + "name": v.Name(), + "model": v.ModelName, + "brand": v.BrandName, + "plate": v.AutoPlate, + }, 1) + + energyType := jsonv.Str(v.EnergyType) + if energyType == "" { + energyType = "0" // EV + } + + data, err := e.client.Realtime(ctx, v.VIN, energyType) + if err != nil { + e.log.Printf("realtime %s: %v", v.VIN, err) + set.Add("byd_vehicle_up", labels, 0) + if firstErr == nil { + firstErr = err + } + continue + } + set.Add("byd_vehicle_up", labels, 1) + set.AddPayload(MetricPrefix, labels, data) + } + return firstErr +} + +// Metrics returns the last rendered exposition. +func (e *Exporter) Metrics() string { + e.mu.RLock() + defer e.mu.RUnlock() + return e.latest +} + +func (e *Exporter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + fmt.Fprint(w, e.Metrics()) +} diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go new file mode 100644 index 0000000..988cc50 --- /dev/null +++ b/internal/exporter/exporter_test.go @@ -0,0 +1,170 @@ +package exporter + +import ( + "context" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.tinetunnel.com/thistine/byd-exporter/internal/byd" + "git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest" +) + +func newExporter(t *testing.T, baseURL string) *Exporter { + t.Helper() + client, err := byd.New(byd.Config{ + Username: "user@example.com", 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: 3, PollInterval: time.Millisecond, + Device: mustDevice(t), + }) + if err != nil { + t.Fatal(err) + } + return New(client, log.New(io.Discard, "", 0)) +} + +func mustDevice(t *testing.T) byd.Device { + t.Helper() + d, err := byd.NewDevice("user@example.com") + if err != nil { + t.Fatal(err) + } + return d +} + +// TestScrapeEndToEnd runs login, vehicle list, realtime and rendering against a +// fake cloud that speaks the real protocol. +func TestScrapeEndToEnd(t *testing.T) { + srv := bydtest.New(t) + exp := newExporter(t, srv.URL) + + if err := exp.Scrape(context.Background()); err != nil { + t.Fatalf("Scrape: %v", err) + } + + got := exp.Metrics() + wantLines := []string{ + `byd_elec_percent{name="Daily",vin="` + bydtest.VIN + `"} 82`, + `byd_endurance_mileage{name="Daily",vin="` + bydtest.VIN + `"} 391`, + `byd_total_mileage{name="Daily",vin="` + bydtest.VIN + `"} 14203`, + `byd_left_front_tirepressure{name="Daily",vin="` + bydtest.VIN + `"} 240`, + `byd_vehicle_up{name="Daily",vin="` + bydtest.VIN + `"} 1`, + `byd_vehicles 1`, + `byd_scrape_success 1`, + } + for _, line := range wantLines { + if !strings.Contains(got, line) { + t.Errorf("missing line %q in:\n%s", line, got) + } + } + if !strings.Contains(got, `byd_vehicle_info{brand="BYD",model="SEALION 7",name="Daily",plate="AB-1234",vin="`) { + t.Errorf("byd_vehicle_info labels are wrong:\n%s", got) + } + // The VIN is a string field of the payload; it must not become a metric. + if strings.Contains(got, "byd_vin") { + t.Errorf("string field leaked into a metric:\n%s", got) + } +} + +// TestScrapeFailurePublishesZero: a broken scrape must still publish, so the +// failure is visible in Prometheus rather than hiding behind stale numbers. +func TestScrapeFailurePublishesZero(t *testing.T) { + dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "upstream is down", http.StatusBadGateway) + })) + defer dead.Close() + + exp := newExporter(t, dead.URL) + if err := exp.Scrape(context.Background()); err == nil { + t.Fatal("expected an error from a failing upstream") + } + + got := exp.Metrics() + if !strings.Contains(got, "byd_scrape_success 0") { + t.Errorf("expected byd_scrape_success 0, got:\n%s", got) + } + if !strings.Contains(got, "byd_scrape_timestamp_seconds") { + t.Errorf("expected a timestamp even on failure, got:\n%s", got) + } +} + +// TestScrapeSurvivesOneBadVehicle: one car that cannot be read must not cost +// the metrics of the others. +func TestScrapeSurvivesOneBadVehicle(t *testing.T) { + srv := bydtest.New(t) + srv.Vehicles = append(bydtest.DefaultVehicles(), map[string]any{ + "vin": "", // skipped: no VIN to label with + "modelName": "Ghost", + }) + exp := newExporter(t, srv.URL) + + if err := exp.Scrape(context.Background()); err != nil { + t.Fatalf("Scrape: %v", err) + } + got := exp.Metrics() + if !strings.Contains(got, "byd_vehicles 2") { + t.Errorf("expected both vehicles counted:\n%s", got) + } + if strings.Contains(got, `vin=""`) { + t.Errorf("a vehicle with no VIN should be skipped:\n%s", got) + } + if !strings.Contains(got, "byd_elec_percent") { + t.Errorf("the good vehicle's metrics went missing:\n%s", got) + } +} + +func TestServeHTTP(t *testing.T) { + srv := bydtest.New(t) + exp := newExporter(t, srv.URL) + + // Before any scrape the body is empty but the request must still succeed, + // so Prometheus sees an up target rather than a connection error. + rec := httptest.NewRecorder() + exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain", ct) + } + + if err := exp.Scrape(context.Background()); err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + exp.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if !strings.Contains(rec.Body.String(), "byd_elec_percent") { + t.Errorf("body has no metrics:\n%s", rec.Body.String()) + } +} + +// TestConcurrentServeAndScrape is a race-detector target: /metrics is served +// while a scrape is replacing it. +func TestConcurrentServeAndScrape(t *testing.T) { + srv := bydtest.New(t) + exp := newExporter(t, srv.URL) + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 3; i++ { + exp.Scrape(context.Background()) + } + }() + for { + select { + case <-done: + return + default: + _ = exp.Metrics() + } + } +} diff --git a/internal/jsonv/jsonv.go b/internal/jsonv/jsonv.go new file mode 100644 index 0000000..e819a1c --- /dev/null +++ b/internal/jsonv/jsonv.go @@ -0,0 +1,54 @@ +// Package jsonv coerces values out of BYD's loosely typed JSON. +// +// The same field arrives as a JSON number in one response and a quoted string +// in the next, and absent fields arrive as null rather than being omitted, so +// every read goes through here rather than through a type assertion. +package jsonv + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// Str renders a value as a string, mapping nil to "" rather than "". +func Str(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case float64: + // JSON has no integers, so ids and codes arrive as float64. Exponent + // notation would corrupt them ("1.23456789e+09" as a user id), so + // integral values are rendered as integers. + if t == math.Trunc(t) && math.Abs(t) < 1e15 { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'g', -1, 64) + case bool: + return strconv.FormatBool(t) + default: + return fmt.Sprint(v) + } +} + +// Num coerces a value to a float. It reports false for anything that is not a +// number, a bool, or a string holding a number. +func Num(v any) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case bool: + if t { + return 1, true + } + return 0, true + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + return f, err == nil + default: + return 0, false + } +} diff --git a/internal/jsonv/jsonv_test.go b/internal/jsonv/jsonv_test.go new file mode 100644 index 0000000..e8e0e4a --- /dev/null +++ b/internal/jsonv/jsonv_test.go @@ -0,0 +1,74 @@ +package jsonv + +import ( + "encoding/json" + "math" + "testing" +) + +func TestStr(t *testing.T) { + tests := []struct { + in any + want string + }{ + {nil, ""}, + {"abc", "abc"}, + {float64(42), "42"}, + {float64(13.5), "13.5"}, + {true, "true"}, + {[]any{1}, "[1]"}, + } + for _, tt := range tests { + if got := Str(tt.in); got != tt.want { + t.Errorf("Str(%v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// TestStrOnDecodedJSON is the case that matters: userId comes back as a JSON +// number, and formatting it as "42" rather than "4.2e+01" is what makes it +// usable as an identifier in later requests. +func TestStrOnDecodedJSON(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(`{"userId":1234567890}`), &v); err != nil { + t.Fatal(err) + } + if got, want := Str(v["userId"]), "1234567890"; got != want { + t.Errorf("Str(userId) = %q, want %q", got, want) + } +} + +func TestNum(t *testing.T) { + tests := []struct { + in any + want float64 + wantOK bool + }{ + {float64(82), 82, true}, + {"82", 82, true}, + {" 13.5 ", 13.5, true}, + {true, 1, true}, + {false, 0, true}, + {"", 0, false}, + {"abc", 0, false}, + {nil, 0, false}, + {map[string]any{}, 0, false}, + {[]any{}, 0, false}, + } + for _, tt := range tests { + got, ok := Num(tt.in) + if ok != tt.wantOK || (ok && got != tt.want) { + t.Errorf("Num(%#v) = (%v, %v), want (%v, %v)", tt.in, got, ok, tt.want, tt.wantOK) + } + } +} + +func TestNumRejectsNonFinite(t *testing.T) { + // A NaN or Inf would render as an invalid metric value, so it is better to + // know which way these go. + for _, s := range []string{"NaN", "Inf", "+Inf"} { + if v, ok := Num(s); ok && !math.IsNaN(v) && !math.IsInf(v, 0) { + t.Errorf("Num(%q) = %v, expected NaN/Inf or a rejection", s, v) + } + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..553b8d8 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,165 @@ +// Package metrics renders a Prometheus text exposition by hand. +// +// A client library would buy registration, collectors and concurrency handling +// that a snapshot of ~200 gauges does not need — the whole format is three line +// shapes, and this way the metric names can come from the payload at runtime. +package metrics + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "git.tinetunnel.com/thistine/byd-exporter/internal/jsonv" +) + +// Labels is a metric's label set. Empty values are dropped on render. +type Labels map[string]string + +type sample struct { + labels Labels + value float64 +} + +// Set is one scrape's worth of gauges, grouped by metric name so that a name +// shared across vehicles emits a single TYPE line. +type Set struct { + byName map[string][]sample +} + +func New() *Set { return &Set{byName: map[string][]sample{}} } + +func (s *Set) Add(name string, labels Labels, value float64) { + s.byName[name] = append(s.byName[name], sample{labels, value}) +} + +// AddPayload emits every numeric field of a BYD payload as prefix + snake(key). +// +// Nothing here is a hand-maintained field list: BYD changes the realtime +// payload between firmware versions, and new fields should appear on their own. +// Non-numeric fields (strings, nested objects) are skipped — a metric can only +// hold a float. +func (s *Set) AddPayload(prefix string, labels Labels, payload map[string]any) { + for k, v := range payload { + f, ok := jsonv.Num(v) + if !ok { + continue + } + if name := prefix + Snake(k); ValidName(name) { + s.Add(name, labels, f) + } + } +} + +// Len reports how many samples the set holds. +func (s *Set) Len() int { + n := 0 + for _, samples := range s.byName { + n += len(samples) + } + return n +} + +// String renders the exposition. Names are sorted, and samples within a name +// are sorted by vin, so the output is stable between scrapes and diffable. +func (s *Set) String() string { + names := make([]string, 0, len(s.byName)) + for n := range s.byName { + names = append(names, n) + } + sort.Strings(names) + + var b strings.Builder + for _, n := range names { + fmt.Fprintf(&b, "# TYPE %s gauge\n", n) + samples := s.byName[n] + sort.SliceStable(samples, func(i, j int) bool { + return samples[i].labels["vin"] < samples[j].labels["vin"] + }) + for _, sm := range samples { + b.WriteString(n) + b.WriteString(formatLabels(sm.labels)) + b.WriteByte(' ') + b.WriteString(formatValue(sm.value)) + b.WriteByte('\n') + } + } + return b.String() +} + +// formatValue keeps whole numbers readable: a unix timestamp should render as +// 1757000000, not 1.757e+09. Both are valid Prometheus, one is greppable. +func formatValue(v float64) string { + if v == math.Trunc(v) && math.Abs(v) < 1e15 { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'g', -1, 64) +} + +func formatLabels(labels Labels) string { + keys := make([]string, 0, len(labels)) + for k, v := range labels { + if v != "" { + keys = append(keys, k) + } + } + if len(keys) == 0 { + return "" + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + // Quotes are added here rather than with %q, which would escape the + // backslashes escapeLabel just added. + parts = append(parts, k+`="`+escapeLabel(labels[k])+`"`) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func escapeLabel(v string) string { + return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(v) +} + +// ValidName reports whether s is a legal Prometheus metric name. +func ValidName(s string) bool { + if s == "" { + return false + } + for i, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_': + case c >= '0' && c <= '9' && i > 0: + default: + return false + } + } + return true +} + +// Snake converts camelCase to snake_case: "leftFrontTirepressure" becomes +// "left_front_tirepressure" and "evSOC" becomes "ev_soc". +func Snake(s string) string { + var b strings.Builder + runes := []rune(s) + for i, r := range runes { + isUpper := r >= 'A' && r <= 'Z' + if isUpper && i > 0 { + prev := runes[i-1] + prevLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') + // A capital starting a new word inside a run of capitals, as the C + // in "evSOCValue". + capThenWord := prev >= 'A' && prev <= 'Z' && i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + if prevLowerOrDigit || capThenWord { + b.WriteByte('_') + } + } + if isUpper { + r += 'a' - 'A' + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..e439f70 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,150 @@ +package metrics + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestSnake(t *testing.T) { + tests := map[string]string{ + "soc": "soc", + "enduranceMileage": "endurance_mileage", + "leftFrontTirepressure": "left_front_tirepressure", + "mainSettingTempNew": "main_setting_temp_new", + "evSOC": "ev_soc", + "evSOCValue": "ev_soc_value", + "tboxVersion": "tbox_version", + "gear2State": "gear2_state", + "": "", + } + for in, want := range tests { + if got := Snake(in); got != want { + t.Errorf("Snake(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidName(t *testing.T) { + valid := []string{"byd_soc", "_x", "a1"} + invalid := []string{"", "1abc", "byd-soc", "byd soc", "byd.soc"} + for _, s := range valid { + if !ValidName(s) { + t.Errorf("ValidName(%q) = false, want true", s) + } + } + for _, s := range invalid { + if ValidName(s) { + t.Errorf("ValidName(%q) = true, want false", s) + } + } +} + +// TestAddPayloadCoercion covers the shapes BYD actually sends: JSON numbers, +// numbers quoted as strings, booleans, and strings that are not numbers at all. +func TestAddPayloadCoercion(t *testing.T) { + var payload map[string]any + raw := `{"elecPercent":82,"speed":"13.5","charging":true,"vin":"LC0","nested":{"a":1},"missing":null}` + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatal(err) + } + + s := New() + s.AddPayload("byd_", Labels{"vin": "LC0"}, payload) + + want := map[string]float64{ + "byd_elec_percent": 82, + "byd_speed": 13.5, + "byd_charging": 1, + } + for name, value := range want { + samples := s.byName[name] + if len(samples) != 1 { + t.Errorf("%s: got %d samples, want 1", name, len(samples)) + continue + } + if samples[0].value != value { + t.Errorf("%s = %v, want %v", name, samples[0].value, value) + } + } + for _, name := range []string{"byd_vin", "byd_nested", "byd_missing"} { + if _, ok := s.byName[name]; ok { + t.Errorf("%s should not have become a metric", name) + } + } +} + +func TestStringFormat(t *testing.T) { + s := New() + s.Add("byd_soc", Labels{"vin": "B", "name": "Second"}, 50) + s.Add("byd_soc", Labels{"vin": "A", "name": "First"}, 82) + s.Add("byd_scrape_success", nil, 1) + + got := s.String() + want := strings.Join([]string{ + "# TYPE byd_scrape_success gauge", + "byd_scrape_success 1", + "# TYPE byd_soc gauge", + `byd_soc{name="First",vin="A"} 82`, + `byd_soc{name="Second",vin="B"} 50`, + "", + }, "\n") + if got != want { + t.Errorf("String() =\n%s\nwant\n%s", got, want) + } +} + +// TestStringEmitsOneTypeLinePerName guards the rule that repeating a TYPE line +// for the same metric makes the exposition invalid for strict parsers. +func TestStringEmitsOneTypeLinePerName(t *testing.T) { + s := New() + for _, vin := range []string{"A", "B", "C"} { + s.Add("byd_soc", Labels{"vin": vin}, 1) + } + if n := strings.Count(s.String(), "# TYPE byd_soc gauge"); n != 1 { + t.Errorf("got %d TYPE lines for byd_soc, want 1", n) + } +} + +func TestStringIsStable(t *testing.T) { + build := func() string { + s := New() + s.AddPayload("byd_", Labels{"vin": "A"}, map[string]any{ + "soc": 1.0, "range": 2.0, "speed": 3.0, "temp": 4.0, + }) + return s.String() + } + // Map iteration order differs per run; the rendered output must not. + first := build() + for i := 0; i < 20; i++ { + if got := build(); got != first { + t.Fatalf("output is not stable across runs:\n%s\nvs\n%s", got, first) + } + } +} + +func TestLabelEscaping(t *testing.T) { + s := New() + s.Add("byd_x", Labels{"name": `He said "hi"\`, "empty": ""}, 1) + + got := s.String() + if !strings.Contains(got, `name="He said \"hi\"\\"`) { + t.Errorf("quotes and backslashes not escaped: %s", got) + } + if strings.Contains(got, "empty") { + t.Errorf("empty labels should be dropped: %s", got) + } +} + +func TestLen(t *testing.T) { + s := New() + if s.Len() != 0 { + t.Errorf("Len() = %d on a new set, want 0", s.Len()) + } + s.Add("a", nil, 1) + s.Add("a", Labels{"vin": "x"}, 2) + s.Add("b", nil, 3) + if s.Len() != 3 { + t.Errorf("Len() = %d, want 3", s.Len()) + } +} diff --git a/testdata/gen_fixtures.py b/testdata/gen_fixtures.py new file mode 100644 index 0000000..0ae8de7 --- /dev/null +++ b/testdata/gen_fixtures.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Regenerate the Go test vectors in crypto_test.go from the reference client. + + pip install --target=/tmp/pybydenv pybyd==0.0.75 cryptography + PYTHONPATH=/tmp/pybydenv python3 gen_fixtures.py + +If the Go tests fail after a pybyd upgrade, run this: BYD rotating the bangcle +tables is the likely cause, and bangcle_tables.bin needs replacing too +(pybyd/data/bangcle_tables.bin inside the wheel). +""" +from pybyd._crypto.bangcle import BangcleCodec +from pybyd._crypto.hashing import md5_hex, pwd_login_key, sha1_mixed, compute_checkcode + +c = BangcleCodec() +for p in ["", "hello", '{"functionType":"pwdLogin"}', "ünïcodé ✓"]: + print(f"{p!r} -> {c.encode_envelope(p)}") +print("md5Hex(hunter2) ", md5_hex("hunter2")) +print("pwdLoginKey(hunter2) ", pwd_login_key("hunter2")) +print("sha1Mixed(abc) ", sha1_mixed("abc")) +print("checkcode ", compute_checkcode({"zeta": "1", "alpha": "2"}))