Skip to content

Instantly share code, notes, and snippets.

@andig
Last active September 22, 2025 14:22
Show Gist options
  • Select an option

  • Save andig/df735978b080723071a24cdb6db0cf71 to your computer and use it in GitHub Desktop.

Select an option

Save andig/df735978b080723071a24cdb6db0cf71 to your computer and use it in GitHub Desktop.
BMW/Mini EU Data Act
module bmw
go 1.25.1
require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/evcc-io/evcc v0.0.0-20250918141728-7759622260dc
github.com/joho/godotenv v1.5.1
golang.org/x/oauth2 v0.31.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"github.com/golang-jwt/jwt/v5"
_ "github.com/joho/godotenv/autoload"
"github.com/samber/lo"
"github.com/spf13/cast"
"golang.org/x/oauth2"
)
type (
GcidIdToken struct {
*oauth2.Token
IdToken string `json:"id_token"`
Gcid string `json:"gcid"`
}
VehicleMapping struct {
Vin string
MappedSince time.Time
MappingType string
}
Container struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
ContainerId string `json:"containerId"`
Created time.Time `json:"created"`
}
CreateContainer struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
TechnicalDescriptors []string `json:"technicalDescriptors"`
}
TelematicDataPoint struct {
Timestamp time.Time
Unit string
Value string
}
TelematicData struct {
TelematicData map[string]TelematicDataPoint
}
)
const (
apiUrl = "https://api-cardata.bmwgroup.com"
fileName = ".bmw-token.json"
)
var Config = &oauth2.Config{
ClientID: os.Getenv("CLIENT_ID"),
Scopes: []string{"authenticate_user", "openid", "cardata:api:read", "cardata:streaming:read"},
Endpoint: oauth2.Endpoint{
DeviceAuthURL: "https://customer.bmwgroup.com/gcdm/oauth/device/code",
TokenURL: "https://customer.bmwgroup.com/gcdm/oauth/token",
AuthStyle: oauth2.AuthStyleInParams,
},
}
func generateToken(client *http.Client) (*GcidIdToken, error) {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
cv := oauth2.GenerateVerifier()
da, err := Config.DeviceAuth(ctx,
oauth2.S256ChallengeOption(cv),
)
if err != nil {
return nil, err
}
fmt.Println("open and confirm:", da.VerificationURIComplete)
bufio.NewReader(os.Stdin).ReadLine()
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
token, err := Config.DeviceAccessToken(ctx, da,
oauth2.VerifierOption(cv),
)
if err != nil {
return nil, err
}
return enrichToken(token)
}
func refreshToken(client *http.Client, token *oauth2.Token) (*GcidIdToken, error) {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
token.Expiry = time.Now()
token, err := Config.TokenSource(ctx, token).Token()
if err != nil {
return nil, err
}
return enrichToken(token)
}
func enrichToken(token *oauth2.Token) (*GcidIdToken, error) {
id, ok := token.Extra("id_token").(string)
if !ok {
log.Fatal("missing id token")
}
fullToken := &GcidIdToken{
Token: token,
IdToken: id,
}
if gcid, ok := token.Extra("gcid").(string); ok {
fullToken.Gcid = gcid
}
b, _ := json.Marshal(fullToken)
return fullToken, os.WriteFile(fileName, b, 0o644)
}
func getVehicleMappings(client *request.Helper) ([]VehicleMapping, error) {
var res []VehicleMapping
err := client.GetJSON(apiUrl+"/customers/vehicles/mappings", &res)
return lo.Filter(res, func(v VehicleMapping, _ int) bool {
return v.MappingType == "PRIMARY"
}), err
}
func getContainers(client *request.Helper) ([]Container, error) {
var res struct {
Containers []Container
}
if err := client.GetJSON(apiUrl+"/customers/containers", &res); err != nil {
return nil, err
}
return lo.Filter(res.Containers, func(c Container, _ int) bool {
return c.Name == "evcc.io"
}), nil
}
func main() {
apiCall := flag.Bool("api", false, "include api")
mqttCall := flag.Bool("mqtt", false, "include mqtt")
deleteContainer := flag.Bool("delete", false, "delete container")
flag.Parse()
util.LogLevel("trace", nil)
request.LogHeaders = true
client := request.NewHelper(util.NewLogger("foo"))
var token *GcidIdToken
f, err := os.Open(fileName)
if err == nil {
err = json.NewDecoder(f).Decode(&token)
}
if err != nil {
token, err = generateToken(client.Client)
}
// if err == nil {
// token, err = refreshToken(client.Client, token.Token)
// }
if err != nil {
log.Fatal(err)
}
if *apiCall {
apiClient := client
apiClient.Transport = &oauth2.Transport{
Source: Config.TokenSource(context.Background(), token.Token),
Base: &transport.Decorator{
Decorator: transport.DecorateHeaders(map[string]string{
"x-version": "v1",
}),
Base: client.Transport,
},
}
mappings, err := getVehicleMappings(apiClient)
if err != nil {
log.Fatal(err)
}
if len(mappings) == 0 {
log.Fatal("could not find primary vehicle mapping")
}
vin := mappings[0].Vin
containers, err := getContainers(apiClient)
if err != nil {
log.Fatal(err)
}
if *deleteContainer && len(containers) == 1 {
req, _ := request.New(http.MethodDelete, apiUrl+"/customers/containers/"+containers[0].ContainerId, nil)
var res any
if err := apiClient.DoJSON(req, &res); err != nil {
log.Fatal(err)
}
containers = nil
}
if len(containers) == 0 {
data := CreateContainer{
Name: "evcc.io",
Purpose: "evcc.io",
TechnicalDescriptors: []string{
// https://mybmwweb-utilities.api.bmw/de-de/utilities/bmw/api/cd/catalogue/file
"vehicle.body.chargingPort.status",
"vehicle.cabin.hvac.preconditioning.status.comfortState",
"vehicle.drivetrain.batteryManagement.header",
"vehicle.drivetrain.electricEngine.charging.hvStatus",
"vehicle.drivetrain.electricEngine.charging.level",
"vehicle.drivetrain.electricEngine.charging.timeToFullyCharged",
"vehicle.drivetrain.electricEngine.kombiRemainingElectricRange",
"vehicle.powertrain.electric.battery.stateOfCharge.target",
"vehicle.powertrain.tractionBattery.charging.port.anyPosition.isPlugged",
"vehicle.vehicle.travelledDistance",
},
}
req, _ := request.New(http.MethodPost, apiUrl+"/customers/containers", request.MarshalJSON(data))
var res any
if err := apiClient.DoJSON(req, &res); err != nil {
log.Fatal(err)
}
if containers, err = getContainers(apiClient); err != nil {
log.Fatal(err)
}
}
if len(containers) > 0 {
var res TelematicData
uri := fmt.Sprintf(apiUrl+"/customers/vehicles/%s/telematicData?containerId=%s", vin, containers[0].ContainerId)
if err := apiClient.GetJSON(uri, &res); err != nil {
log.Fatal(err)
}
for k, v := range res.TelematicData {
var val any
if f, err := cast.ToFloat64E(v.Value); err == nil {
val = f
} else {
val = v.Value
}
fmt.Println(k, v.Timestamp, val, v.Unit)
}
}
}
if *mqttCall {
mqtt.ERROR = log.New(os.Stdout, "[ERROR] ", 0)
mqtt.CRITICAL = log.New(os.Stdout, "[CRIT] ", 0)
mqtt.WARN = log.New(os.Stdout, "[WARN] ", 0)
// mqtt.DEBUG = log.New(os.Stdout, "[DEBUG] ", 0)
// read token
var claims jwt.RegisteredClaims
if idToken, err := jwt.ParseWithClaims(token.IdToken, &claims, nil); err != nil && !errors.Is(err, jwt.ErrTokenUnverifiable) {
log.Fatal(err)
} else if !idToken.Valid {
if token, err = refreshToken(client.Client, token.Token); err != nil {
log.Fatal("token refresh:", err)
}
}
o := mqtt.NewClientOptions()
o.AddBroker("tls://customer.streaming-cardata.bmwgroup.com:9000")
o.SetAutoReconnect(true)
o.SetUsername(token.Gcid)
o.SetPassword(token.IdToken)
paho := mqtt.NewClient(o)
timeout := 30 * time.Second
if t := paho.Connect(); !t.WaitTimeout(timeout) {
log.Fatal("connect timeout")
} else if err := t.Error(); err != nil {
log.Fatal("connect:", err)
}
// vin := "WBY8P610007J07264"
topic := fmt.Sprintf("%s/#", token.Gcid)
// topic = "#"
fmt.Println("gcid:", token.Gcid)
fmt.Println("id_token:", token.IdToken)
fmt.Println("topic:", topic)
if t := paho.Subscribe(topic, 0, func(c mqtt.Client, m mqtt.Message) {
fmt.Println(m.Topic, string(m.Payload()))
}); !t.WaitTimeout(timeout) {
log.Fatal("subcribe timeout")
} else if err := t.Error(); err != nil {
log.Fatal("subscribe:", err)
}
fmt.Println("waiting for mqtt")
// time.Sleep(30 * time.Second)
time.Sleep(time.Hour)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment