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]>
165 lines
6.7 KiB
Markdown
165 lines
6.7 KiB
Markdown
# 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.
|