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

Go port of the read path of pybyd 0.0.75 (the client behind
jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the
signing/checkcode layer, login, vehicle list, and realtime readings.

Scraping runs on a cron schedule rather than per Prometheus request
because each reading wakes the car's T-Box and drains the traction
battery; /metrics serves the last result.

Every numeric field of the realtime payload becomes a gauge, so the
exporter does not need a hand-maintained field list that goes stale
when BYD changes the payload.

Tests pin the wire format against pybyd byte-for-byte and drive the
client end to end against a fake cloud that speaks the real protocol.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
thistineandClaude Opus 5 committed 2026-09-05 22:03:16 +07:00
commit 52e1aaa0c0
30 files changed
+3501

No files matched your search

+164
View File
@@ -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='[email protected]' 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-<region>.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_<snake_case_field>{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.