Last active
September 22, 2025 14:22
-
-
Save andig/df735978b080723071a24cdb6db0cf71 to your computer and use it in GitHub Desktop.
BMW/Mini EU Data Act
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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