# 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.