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:
commit
52e1aaa0c0
30 files changed
+3501
No files matched your search
@@ -0,0 +1,6 @@
|
||||
BYD_USERNAME=[email protected]
|
||||
BYD_PASSWORD=your-byd-app-password
|
||||
BYD_REGION=sg
|
||||
BYD_COUNTRY_CODE=TH
|
||||
BYD_TIME_ZONE=Asia/Bangkok
|
||||
BYD_SCHEDULE=0 * * * *
|
||||
@@ -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/[email protected]
|
||||
- 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 }}
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- 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 ./...
|
||||
@@ -0,0 +1,2 @@
|
||||
/byd-exporter
|
||||
.env
|
||||
+11
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.tinetunnel.com/thistine/byd-exporter
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package byd is a read-only client for the BYD cloud API — the same endpoints
|
||||
// the BYD mobile app calls. Ported from pybyd 0.0.75.
|
||||
//
|
||||
// Every request is wrapped twice:
|
||||
//
|
||||
// outer JSON -> bangcle white-box AES -> {"request": "F<base64>"}
|
||||
// encryData -> AES-128-CBC (hex) with a key derived from the session token
|
||||
//
|
||||
// The outer envelope carries the device identity and a signature; the inner
|
||||
// encryData carries the actual request fields.
|
||||
//
|
||||
// MQTT push is deliberately not implemented. pybyd waits for a push and falls
|
||||
// back to HTTP polling on timeout; this client only polls, which costs a few
|
||||
// extra requests per reading and is plenty for scraping.
|
||||
package byd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tinetunnel.com/thistine/byd-exporter/internal/bangcle"
|
||||
"git.tinetunnel.com/thistine/byd-exporter/internal/jsonv"
|
||||
)
|
||||
|
||||
const userAgent = "okhttp/4.12.0"
|
||||
|
||||
// sessionExpiredCodes are the API codes that mean "log in again".
|
||||
var sessionExpiredCodes = map[string]bool{"1002": true, "1005": true, "1010": true}
|
||||
|
||||
// Config holds everything needed to impersonate one app install.
|
||||
type Config struct {
|
||||
Username, Password string
|
||||
BaseURL string
|
||||
CountryCode string
|
||||
Language string
|
||||
TimeZone string
|
||||
AppName string
|
||||
AppVersion string
|
||||
AppInnerVersion string
|
||||
SoftType string
|
||||
TboxVersion string
|
||||
IsAuto string
|
||||
SessionTTL time.Duration
|
||||
PollAttempts int
|
||||
PollInterval time.Duration
|
||||
Device Device
|
||||
}
|
||||
|
||||
type session struct {
|
||||
userID, signToken, encryToken string
|
||||
created time.Time
|
||||
}
|
||||
|
||||
// The two AES keys for a logged-in session are derived from the tokens the
|
||||
// login response carries, not from the password.
|
||||
func (s *session) contentKey() string { return md5Hex(s.encryToken) }
|
||||
func (s *session) signKey() string { return md5Hex(s.signToken) }
|
||||
|
||||
// Client is not safe for concurrent use: one scrape at a time.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
sess *session
|
||||
|
||||
// Overridden in tests to reproduce a payload byte for byte.
|
||||
now func() time.Time
|
||||
nonce func() string
|
||||
}
|
||||
|
||||
// New returns a client with a cookie jar; BYD sets session cookies the
|
||||
// subsequent requests need.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: &http.Client{Jar: jar, Timeout: 30 * time.Second},
|
||||
now: time.Now,
|
||||
nonce: randomHex16,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// APIError is a non-zero response code from an endpoint. Callers branch on Code
|
||||
// to tell "session expired" from "car asleep" from a real failure.
|
||||
type APIError struct {
|
||||
Endpoint, Code, Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("%s failed: code=%s message=%s", e.Endpoint, e.Code, e.Message)
|
||||
}
|
||||
|
||||
func randomHex16() string {
|
||||
b := make([]byte, 16)
|
||||
// A request nonce, not a key: math/rand is fine and avoids an error path.
|
||||
rand.Read(b)
|
||||
return strings.ToUpper(hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
// postSecure sends one bangcle-wrapped request and returns the decoded outer
|
||||
// response. The respondData inside is still encrypted at this point.
|
||||
func (c *Client) postSecure(ctx context.Context, endpoint string, outer jmap) (map[string]any, error) {
|
||||
payload, err := marshalCompact(outer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{"request": bangcle.Encode(payload)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.BaseURL+endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("accept-encoding", "identity")
|
||||
req.Header.Set("content-type", "application/json; charset=UTF-8")
|
||||
req.Header.Set("user-agent", userAgent)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d: %.200s", endpoint, resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("%s: invalid JSON: %.200s", endpoint, raw)
|
||||
}
|
||||
if strings.TrimSpace(envelope.Response) == "" {
|
||||
return nil, fmt.Errorf("%s: empty response payload", endpoint)
|
||||
}
|
||||
|
||||
decoded, err := bangcle.Decode(envelope.Response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
text := strings.TrimSpace(string(decoded))
|
||||
// Some responses come back with a stray F prefix on the decoded JSON.
|
||||
if strings.HasPrefix(text, "F{") || strings.HasPrefix(text, "F[") {
|
||||
text = text[1:]
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
return nil, fmt.Errorf("%s: decoded payload is not JSON: %.64s", endpoint, text)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// commonOuter adds the device identity fields every envelope carries.
|
||||
func (c *Client) commonOuter(outer *jmap) {
|
||||
d := c.cfg.Device
|
||||
outer.set("ostype", d.OSType)
|
||||
outer.set("imei", d.IMEI)
|
||||
outer.set("mac", d.MAC)
|
||||
outer.set("model", d.Model)
|
||||
outer.set("sdk", d.SDK)
|
||||
outer.set("mod", d.Mod)
|
||||
}
|
||||
|
||||
// innerBase builds the fields every post-login endpoint sends.
|
||||
func (c *Client) innerBase(vin, requestSerial string) jmap {
|
||||
inner := jmap{}
|
||||
inner.set("deviceType", c.cfg.Device.DeviceType)
|
||||
inner.set("imeiMD5", c.cfg.Device.IMEIMD5)
|
||||
inner.set("networkType", c.cfg.Device.NetworkType)
|
||||
inner.set("random", c.nonce())
|
||||
inner.set("timeStamp", fmt.Sprint(c.now().UnixMilli()))
|
||||
inner.set("version", c.cfg.AppInnerVersion)
|
||||
if vin != "" {
|
||||
inner.set("vin", vin)
|
||||
}
|
||||
if requestSerial != "" {
|
||||
inner.set("requestSerial", requestSerial)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// postToken signs, encrypts and sends a post-login request, returning the
|
||||
// decrypted respondData as raw JSON. It may be an object or an array.
|
||||
func (c *Client) postToken(ctx context.Context, endpoint string, inner jmap) (json.RawMessage, error) {
|
||||
cfg := c.cfg
|
||||
d := cfg.Device
|
||||
s := c.sess
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("%s: not logged in", endpoint)
|
||||
}
|
||||
reqTimestamp := fmt.Sprint(c.now().UnixMilli())
|
||||
contentKey := s.contentKey()
|
||||
|
||||
innerJSON, err := marshalCompact(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encryData, err := aesEncryptHex(string(innerJSON), contentKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signFields := inner.clone()
|
||||
signFields.set("countryCode", cfg.CountryCode)
|
||||
signFields.set("identifier", s.userID)
|
||||
signFields.set("imeiMD5", d.IMEIMD5)
|
||||
signFields.set("language", cfg.Language)
|
||||
signFields.set("reqTimestamp", reqTimestamp)
|
||||
|
||||
outer := jmap{}
|
||||
outer.set("countryCode", cfg.CountryCode)
|
||||
outer.set("encryData", encryData)
|
||||
outer.set("identifier", s.userID)
|
||||
outer.set("imeiMD5", d.IMEIMD5)
|
||||
outer.set("language", cfg.Language)
|
||||
outer.set("reqTimestamp", reqTimestamp)
|
||||
outer.set("sign", sha1Mixed(buildSignString(signFields, s.signKey())))
|
||||
c.commonOuter(&outer)
|
||||
outer.set("serviceTime", fmt.Sprint(c.now().UnixMilli()))
|
||||
code, err := checkcode(outer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outer.set("checkcode", code)
|
||||
|
||||
resp, err := c.postSecure(ctx, endpoint, outer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rc := jsonv.Str(resp["code"]); rc != "0" {
|
||||
return nil, &APIError{endpoint, rc, jsonv.Str(resp["message"])}
|
||||
}
|
||||
|
||||
respondData := jsonv.Str(resp["respondData"])
|
||||
if respondData == "" {
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
plaintext, err := aesDecryptUTF8(respondData, contentKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", endpoint, err)
|
||||
}
|
||||
if strings.TrimSpace(plaintext) == "" {
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
if !json.Valid([]byte(plaintext)) {
|
||||
return nil, fmt.Errorf("%s: respondData is not JSON: %.128s", endpoint, plaintext)
|
||||
}
|
||||
return json.RawMessage(plaintext), nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package byd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.tinetunnel.com/thistine/byd-exporter/internal/byd/bydtest"
|
||||
)
|
||||
|
||||
// testDevice is the fingerprint the pybyd reference payload was generated with.
|
||||
var testDevice = Device{
|
||||
OSType: "and", IMEI: "356271112345674", MAC: "02:11:22:33:44:55",
|
||||
Model: "SM-S928B", SDK: "35", Mod: "samsung",
|
||||
IMEIMD5: "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
MobileBrand: "SAMSUNG", MobileModel: "SM-S928B",
|
||||
DeviceType: "0", NetworkType: "wifi", AndroidVer: "15", OSVersion: "35",
|
||||
}
|
||||
|
||||
func testConfig(baseURL string) Config {
|
||||
return Config{
|
||||
Username: "[email protected]", Password: bydtest.Password,
|
||||
BaseURL: baseURL,
|
||||
CountryCode: "NL", Language: "en", TimeZone: "UTC",
|
||||
AppName: "pyBYD+0.0.75", AppVersion: "3.5.1", AppInnerVersion: "351",
|
||||
SoftType: "0", TboxVersion: "3", IsAuto: "1",
|
||||
SessionTTL: time.Hour, PollAttempts: 5, PollInterval: time.Millisecond,
|
||||
Device: testDevice,
|
||||
}
|
||||
}
|
||||
|
||||
// newTestClient freezes the clock and the nonce so payloads are reproducible.
|
||||
func newTestClient(t *testing.T, cfg Config) *Client {
|
||||
t.Helper()
|
||||
c, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
c.nonce = func() string { return strings.Repeat("0", 32) }
|
||||
return c
|
||||
}
|
||||
|
||||
// TestLoginRequestMatchesPybyd compares a complete login payload — field order,
|
||||
// encryData, sign and checkcode — against pybyd's build_login_request run with
|
||||
// the same frozen clock and nonce. This is what catches a renamed, missing or
|
||||
// misordered field, which the primitive tests in crypto_test.go cannot see.
|
||||
func TestLoginRequestMatchesPybyd(t *testing.T) {
|
||||
const want = `{"appName":"pyBYD+0.0.75","countryCode":"NL","encryData":"2F01666A8BC0E6DBD65D2CB5EDF6AA1D9CD01D40F48A5EDE699779B21E399C5223841BD789B595E1BE40C5A0BCA07A26A0B649AB6DD3E63BC4F112D71E0EC4A904312DBF56DEBA50F407B678C1A1E8FD8AFC4F76520AD7D9EB03D9C430160C787C209DE5B38133CFBD9123E4F935E86A42EE732731F336B740BFDB206EEC7A26D756B1CF5ADF20F595532810A6019F5A717C21475DBDBA5F5415E0D4CA1AD26433761852FEE204DA496E2C775EF22C30B5EEBDCBEA164D9518F2345EC5DE5E70A6CC22B8D18B2B7BB9AEED652F2D123B2D96E5787C0EA39E20DBE529A2A4EB4EC51899D5A3CB2AEC8FF1EA1561CBA7D347C82CC075A4ECD92A48549F5492D4B4C0E487F3A45F8C7060880EF881254DBA578FAE4CBE8AA8777EFF7A26272E61867ABB33ECB752476BBB69A257CFA57C4C6E1269DA5EDFED322F13202ADC28B318C8868DFEDF3138E0A6655BB7B037480613975F5A437A698138DEA7DD26DB0B0F1445DE3D6B3C3B1B15D6DEAEFF9545EB9EE29F17A1C77BD4239ACDC00310571E1C338AE783106FDFAC488A0C6E0A4659","functionType":"pwdLogin","identifier":"[email protected]","identifierType":"0","imeiMD5":"deadbeefdeadbeefdeadbeefdeadbeef","isAuto":"1","language":"en","reqTimestamp":"1700000000000","sign":"97d29Abe36be116c9A3681703Ead98ad86c0D088","signKey":"hunter2","ostype":"and","imei":"356271112345674","mac":"02:11:22:33:44:55","model":"SM-S928B","sdk":"35","mod":"samsung","serviceTime":"1700000000000","checkcode":"8ac193b5be6e01d003b5c1da9e9036d8"}`
|
||||
|
||||
c := newTestClient(t, testConfig("http://example.invalid"))
|
||||
outer, err := c.loginRequest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := marshalCompact(outer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("login payload differs from pybyd\n got %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndVehicles(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
c := newTestClient(t, testConfig(srv.URL))
|
||||
ctx := context.Background()
|
||||
|
||||
if err := c.EnsureSession(ctx); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
// userId arrives as a JSON number; it has to survive as "42", not "4.2e+01".
|
||||
if c.sess.userID != "42" {
|
||||
t.Errorf("userID = %q, want 42", c.sess.userID)
|
||||
}
|
||||
|
||||
vehicles, err := c.Vehicles(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Vehicles: %v", err)
|
||||
}
|
||||
if len(vehicles) != 1 {
|
||||
t.Fatalf("got %d vehicles, want 1", len(vehicles))
|
||||
}
|
||||
if got, want := vehicles[0].VIN, bydtest.VIN; got != want {
|
||||
t.Errorf("VIN = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := vehicles[0].Name(), "Daily"; got != want {
|
||||
t.Errorf("Name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimePollsUntilReady drives the trigger-then-poll flow: the trigger
|
||||
// only acknowledges, and the reading appears on a later poll.
|
||||
func TestRealtimePollsUntilReady(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
srv.ReadyAfterPolls = 3
|
||||
|
||||
c := newTestClient(t, testConfig(srv.URL))
|
||||
ctx := context.Background()
|
||||
if err := c.EnsureSession(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := c.Realtime(ctx, bydtest.VIN, "0")
|
||||
if err != nil {
|
||||
t.Fatalf("Realtime: %v", err)
|
||||
}
|
||||
if got := data["elecPercent"]; got != float64(82) {
|
||||
t.Errorf("elecPercent = %v, want 82", got)
|
||||
}
|
||||
if srv.Polls != 3 {
|
||||
t.Errorf("polled %d times, want 3 (stopping as soon as the car answers)", srv.Polls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimeGivesUpOnSleepingCar covers the car that never answers: the
|
||||
// trigger's payload comes back instead of an error, so the scrape still
|
||||
// produces metrics for the rest of the fleet.
|
||||
func TestRealtimeGivesUpOnSleepingCar(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
srv.ReadyAfterPolls = 1000 // never within PollAttempts
|
||||
|
||||
cfg := testConfig(srv.URL)
|
||||
cfg.PollAttempts = 3
|
||||
c := newTestClient(t, cfg)
|
||||
ctx := context.Background()
|
||||
if err := c.EnsureSession(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := c.Realtime(ctx, bydtest.VIN, "0")
|
||||
if err != nil {
|
||||
t.Fatalf("Realtime errored on a sleeping car: %v", err)
|
||||
}
|
||||
if _, ok := data["requestSerial"]; !ok {
|
||||
t.Errorf("expected the last payload seen, got %v", data)
|
||||
}
|
||||
if srv.Polls != 3 {
|
||||
t.Errorf("polled %d times, want PollAttempts=3", srv.Polls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealtimeStopsOnCancelledContext keeps a hung scrape from outliving its
|
||||
// timeout: the poll loop has to watch the context, not just sleep.
|
||||
func TestRealtimeStopsOnCancelledContext(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
srv.ReadyAfterPolls = 1000
|
||||
|
||||
cfg := testConfig(srv.URL)
|
||||
cfg.PollAttempts = 1000
|
||||
cfg.PollInterval = 10 * time.Millisecond
|
||||
c := newTestClient(t, cfg)
|
||||
|
||||
if err := c.EnsureSession(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Realtime(ctx, bydtest.VIN, "0")
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Realtime ignored the cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailureReturnsAPIError(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
srv.LoginCode = "1003"
|
||||
|
||||
err := newTestClient(t, testConfig(srv.URL)).Login(context.Background())
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("got %T (%v), want *APIError", err, err)
|
||||
}
|
||||
if apiErr.Code != "1003" {
|
||||
t.Errorf("Code = %q, want 1003", apiErr.Code)
|
||||
}
|
||||
if !strings.Contains(apiErr.Error(), loginEndpoint) {
|
||||
t.Errorf("error does not name the endpoint: %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSessionReusesUntilTTL: a fresh login per scrape would be both slow
|
||||
// and a good way to get the account flagged.
|
||||
func TestEnsureSessionReusesUntilTTL(t *testing.T) {
|
||||
srv := bydtest.New(t)
|
||||
|
||||
cfg := testConfig(srv.URL)
|
||||
cfg.SessionTTL = time.Hour
|
||||
c := newTestClient(t, cfg)
|
||||
|
||||
now := time.UnixMilli(1700000000000)
|
||||
c.now = func() time.Time { return now }
|
||||
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := c.EnsureSession(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if srv.Logins != 1 {
|
||||
t.Errorf("logged in %d times, want 1 while inside the TTL", srv.Logins)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Hour)
|
||||
if err := c.EnsureSession(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if srv.Logins != 2 {
|
||||
t.Errorf("logged in %d times, want a re-login once the TTL passed", srv.Logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointsRequireLogin(t *testing.T) {
|
||||
c := newTestClient(t, testConfig("http://example.invalid"))
|
||||
if _, err := c.Vehicles(context.Background()); err == nil {
|
||||
t.Error("expected an error when calling an endpoint before logging in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeReady(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"ack only", map[string]any{"requestSerial": "abc"}, false},
|
||||
{"offline wins over data", map[string]any{"onlineState": 2.0, "enduranceMileage": 300.0}, false},
|
||||
{"tyre pressure", map[string]any{"leftFrontTirepressure": 240.0}, true},
|
||||
{"rear tyre pressure", map[string]any{"rightRearTirepressure": 235.0}, true},
|
||||
{"timestamp", map[string]any{"time": 1.7e12}, true},
|
||||
{"range", map[string]any{"enduranceMileage": 391.0}, true},
|
||||
{"zero range only", map[string]any{"enduranceMileage": 0.0}, false},
|
||||
{"range as a string", map[string]any{"enduranceMileage": "391"}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := RealtimeReady(tt.info); got != tt.want {
|
||||
t.Errorf("RealtimeReady(%v) = %v, want %v", tt.info, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleName(t *testing.T) {
|
||||
tests := []struct {
|
||||
v Vehicle
|
||||
want string
|
||||
}{
|
||||
{Vehicle{AutoAlias: "Daily", AutoPlate: "AB-123", ModelName: "Sealion 7"}, "Daily"},
|
||||
{Vehicle{AutoPlate: "AB-123", ModelName: "Sealion 7"}, "AB-123"},
|
||||
{Vehicle{ModelName: "Sealion 7"}, "Sealion 7"},
|
||||
{Vehicle{VIN: "LC0"}, "LC0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.v.Name(); got != tt.want {
|
||||
t.Errorf("Name() = %q, want %q", got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package byd
|
||||
|
||||
// Expected values come from pybyd 0.0.75 — regenerate with testdata/gen_fixtures.py.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{"md5Hex", md5Hex("hunter2"), "2AB96390C7DBE3439DE74D0C9B0B1767"},
|
||||
{"pwdLoginKey", pwdLoginKey("hunter2"), "38075EADB88F8484DDC00C4D272D9F4C"},
|
||||
{"sha1Mixed", sha1Mixed("abc"), "A9993E36476816aBA3e25717850C26c9Cd0D89d"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s = %s, want %s", tt.name, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSha1MixedDropsZeros covers the odd half of the algorithm. Note the
|
||||
// filtering is indexed against the pre-filter string, not the result, so the
|
||||
// output can still contain '0' at an even position — the only thing guaranteed
|
||||
// is that it comes out shorter than a plain 40-char hex digest.
|
||||
func TestSha1MixedDropsZeros(t *testing.T) {
|
||||
got := sha1Mixed("abc")
|
||||
if len(got) >= 40 {
|
||||
t.Errorf("sha1Mixed(%q) = %q (%d chars); zero filtering did not run", "abc", got, len(got))
|
||||
}
|
||||
if strings.ToLower(got) == got || strings.ToUpper(got) == got {
|
||||
t.Errorf("sha1Mixed(%q) = %q; expected mixed case", "abc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJmapPreservesInsertionOrder(t *testing.T) {
|
||||
// A Go map would sort these keys and change the checkcode, so this is the
|
||||
// guard on the ordered marshalling the whole protocol depends on.
|
||||
m := jmap{}
|
||||
m.set("zeta", "1")
|
||||
m.set("alpha", "2")
|
||||
|
||||
b, err := marshalCompact(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `{"zeta":"1","alpha":"2"}`; string(b) != want {
|
||||
t.Fatalf("marshalCompact = %s, want %s", b, want)
|
||||
}
|
||||
|
||||
got, err := checkcode(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "3d6e01c0671c8e4d2e2fbda3466886b0"; got != want {
|
||||
t.Errorf("checkcode = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJmapSetOverwritesInPlace(t *testing.T) {
|
||||
m := jmap{}
|
||||
m.set("a", "1")
|
||||
m.set("b", "2")
|
||||
m.set("a", "3") // must update, not append, or the order shifts
|
||||
|
||||
b, _ := marshalCompact(m)
|
||||
if want := `{"a":"3","b":"2"}`; string(b) != want {
|
||||
t.Errorf("marshalCompact = %s, want %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJmapCloneIsIndependent(t *testing.T) {
|
||||
orig := jmap{}
|
||||
orig.set("a", "1")
|
||||
|
||||
clone := orig.clone()
|
||||
clone.set("b", "2")
|
||||
clone.set("a", "changed")
|
||||
|
||||
if orig.get("a") != "1" || len(orig) != 1 {
|
||||
t.Errorf("clone mutated the original: %v", orig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalCompactDoesNotEscapeHTML guards a real difference between Go and
|
||||
// Python: encoding/json escapes < > & by default, which would change every
|
||||
// checkcode for a payload containing them.
|
||||
func TestMarshalCompactDoesNotEscapeHTML(t *testing.T) {
|
||||
m := jmap{}
|
||||
m.set("q", "a<b&c>d")
|
||||
b, err := marshalCompact(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `{"q":"a<b&c>d"}`; string(b) != want {
|
||||
t.Errorf("marshalCompact = %s, want %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignStringSortsKeys(t *testing.T) {
|
||||
m := jmap{}
|
||||
m.set("b", "2")
|
||||
m.set("a", "1")
|
||||
m.set("c", "3")
|
||||
if got, want := buildSignString(m, "pw"), "a=1&b=2&c=3&password=pw"; got != want {
|
||||
t.Errorf("buildSignString = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESRoundTrip(t *testing.T) {
|
||||
key := pwdLoginKey("hunter2")
|
||||
const plain = `{"a":1,"b":"ü"}`
|
||||
|
||||
ct, err := aesEncryptHex(plain, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := aesDecryptUTF8(ct, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Errorf("round trip = %q, want %q", got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESRejectsBadInput(t *testing.T) {
|
||||
key := pwdLoginKey("hunter2")
|
||||
if _, err := aesEncryptHex("x", "not-hex"); err == nil {
|
||||
t.Error("expected an error for a non-hex key")
|
||||
}
|
||||
if _, err := aesEncryptHex("x", "AABB"); err == nil {
|
||||
t.Error("expected an error for a 2-byte key")
|
||||
}
|
||||
if _, err := aesDecryptUTF8("zz", key); err == nil {
|
||||
t.Error("expected an error for non-hex ciphertext")
|
||||
}
|
||||
if _, err := aesDecryptUTF8("AABB", key); err == nil {
|
||||
t.Error("expected an error for a partial block")
|
||||
}
|
||||
}
|
||||
@@ -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, ":")
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
package byd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDeviceIsStable is the point of the whole seeded-derivation approach: a
|
||||
// restart must not look like a new phone to BYD.
|
||||
func TestDeviceIsStable(t *testing.T) {
|
||||
a, err := NewDevice("[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewDevice("[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("same seed gave different devices:\n%+v\n%+v", a, b)
|
||||
}
|
||||
|
||||
c, err := NewDevice("[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.IMEI == a.IMEI {
|
||||
t.Error("different seeds produced the same IMEI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLooksRealistic(t *testing.T) {
|
||||
d, err := NewDevice("[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`^\d{15}$`).MatchString(d.IMEI) {
|
||||
t.Errorf("IMEI %q is not 15 digits", d.IMEI)
|
||||
}
|
||||
if got, want := luhn(d.IMEI[:14]), string(d.IMEI[14]); got != want {
|
||||
t.Errorf("IMEI %q fails the Luhn check (want check digit %s)", d.IMEI, got)
|
||||
}
|
||||
if !regexp.MustCompile(`^([0-9a-f]{2}:){5}[0-9a-f]{2}$`).MatchString(d.MAC) {
|
||||
t.Errorf("MAC %q is malformed", d.MAC)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d.IMEIMD5) {
|
||||
t.Errorf("IMEIMD5 %q is not a lowercase MD5", d.IMEIMD5)
|
||||
}
|
||||
for name, v := range map[string]string{
|
||||
"Model": d.Model, "MobileBrand": d.MobileBrand, "MobileModel": d.MobileModel,
|
||||
"Mod": d.Mod, "SDK": d.SDK, "AndroidVer": d.AndroidVer,
|
||||
} {
|
||||
if v == "" {
|
||||
t.Errorf("%s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceMACIsLocallyAdministered keeps the generated address out of the
|
||||
// space of real vendor MACs.
|
||||
func TestDeviceMACIsLocallyAdministered(t *testing.T) {
|
||||
for _, seed := range []string{"a", "b", "c", "d", "e"} {
|
||||
d, err := NewDevice(seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := strconv.ParseInt(d.MAC[:2], 16, 16)
|
||||
if err != nil {
|
||||
t.Fatalf("MAC %q: %v", d.MAC, err)
|
||||
}
|
||||
if first&0x02 == 0 {
|
||||
t.Errorf("MAC %q is not locally administered", d.MAC)
|
||||
}
|
||||
if first&0x01 != 0 {
|
||||
t.Errorf("MAC %q is a multicast address", d.MAC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePoolIsUsable(t *testing.T) {
|
||||
var pool []poolEntry
|
||||
if err := json.Unmarshal(devicePoolJSON, &pool); err != nil {
|
||||
t.Fatalf("device_pool.json is not valid JSON: %v", err)
|
||||
}
|
||||
if len(pool) < 2 {
|
||||
t.Fatalf("device pool has %d entries; a pool of one defeats the point", len(pool))
|
||||
}
|
||||
for i, e := range pool {
|
||||
if len(e.TACPrefix) != 8 {
|
||||
t.Errorf("entry %d: TAC prefix %q is not 8 digits", i, e.TACPrefix)
|
||||
}
|
||||
if len(e.SDKOptions) == 0 || len(e.SDKOptions) != len(e.OSOptions) {
|
||||
t.Errorf("entry %d: sdk_options and os_options must pair up (%d vs %d)",
|
||||
i, len(e.SDKOptions), len(e.OSOptions))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuhn(t *testing.T) {
|
||||
// Known-good IMEIs; the 15th digit must be what luhn derives from the first 14.
|
||||
for _, imei := range []string{"490154203237518", "356938035643809"} {
|
||||
if got, want := luhn(imei[:14]), string(imei[14]); got != want {
|
||||
t.Errorf("luhn(%s) = %s, want %s", imei[:14], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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: "[email protected]", Password: bydtest.Password,
|
||||
BaseURL: baseURL,
|
||||
CountryCode: "NL", Language: "en", TimeZone: "UTC",
|
||||
AppName: "pyBYD+0.0.75", AppVersion: "3.5.1", AppInnerVersion: "351",
|
||||
SoftType: "0", TboxVersion: "3", IsAuto: "1",
|
||||
SessionTTL: time.Hour, PollAttempts: 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("[email protected]")
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "<nil>".
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -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"}))
|
||||
Reference in new issue
Block a user