diff --git a/api/api.go b/api/api.go index f27eb7783..7e3df35ea 100644 --- a/api/api.go +++ b/api/api.go @@ -209,9 +209,9 @@ type Tariff interface { // AuthProvider is the ability to provide OAuth authentication through the ui type AuthProvider interface { - Login(state string) string + Login(state string) (string, error) Logout() error - HandleCallback(responseValues url.Values) error + HandleCallback(params url.Values) error Authenticated() bool DisplayName() string } diff --git a/api/error.go b/api/error.go index 672dddd3f..2e0d20780 100644 --- a/api/error.go +++ b/api/error.go @@ -14,6 +14,9 @@ var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc // ErrMissingCredentials indicates that user/password are missing var ErrMissingCredentials = errors.New("missing user/password credentials") +// ErrLoginRequired indicates that retrieving tokens credentials waits for login +var ErrLoginRequired = errors.New("login required") + // ErrMissingToken indicates that access/refresh tokens are missing var ErrMissingToken = errors.New("missing token credentials") diff --git a/plugin/auth/oauth.go b/plugin/auth/oauth.go index ed2a0e62c..19a06ce1a 100644 --- a/plugin/auth/oauth.go +++ b/plugin/auth/oauth.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "sync" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/server/db/settings" @@ -26,6 +27,30 @@ type OAuth struct { subject string cv string ctx context.Context + + deviceFlow bool + tokenRetriever func(string, *oauth2.Token) error + tokenStorer func(*oauth2.Token) any +} + +type oauthOption func(*OAuth) + +func WithOauthDeviceFlowOption() func(o *OAuth) { + return func(o *OAuth) { + o.deviceFlow = true + } +} + +func WithTokenStorerOption(ts func(*oauth2.Token) any) func(o *OAuth) { + return func(o *OAuth) { + o.tokenStorer = ts + } +} + +func WithTokenRetrieverOption(tr func(string, *oauth2.Token) error) func(o *OAuth) { + return func(o *OAuth) { + o.tokenRetriever = tr + } } var ( @@ -59,20 +84,23 @@ func NewOauthFromConfig(ctx context.Context, other map[string]any) (oauth2.Token return nil, err } - return NewOauth(ctx, &cc.Config, cc.Name) + return NewOauth(ctx, cc.Name, &cc.Config) } -func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oauth2.TokenSource, error) { - log := util.NewLogger("oauth-generic") +var ( + _ oauth.TokenRefresher = (*OAuth)(nil) + _ api.AuthProvider = (*OAuth)(nil) +) - if instanceName == "" { +func NewOauth(ctx context.Context, name string, oc *oauth2.Config, opts ...oauthOption) (oauth2.TokenSource, error) { + if name == "" { return nil, errors.New("instance name must not be empty") } // hash oauth2 config h := sha256.Sum256(fmt.Append(nil, oc)) hash := hex.EncodeToString(h[:])[:8] - subject := instanceName + " (" + hash + ")" + subject := name + " (" + hash + ")" // reuse instance if instance := getInstance(subject); instance != nil { @@ -83,21 +111,36 @@ func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oaut o := &OAuth{ subject: subject, oc: oc, - log: log, + log: util.NewLogger("oauth"), ctx: ctx, } + for _, opt := range opts { + opt(o) + } + // load token from db - var tok oauth2.Token + var token oauth2.Token if settings.Exists(o.subject) { o.log.DEBUG.Printf("loading token for %s from database", o.subject) - if err := settings.Json(o.subject, &tok); err != nil { - return nil, err + if o.tokenRetriever != nil { + plain, err := settings.String(o.subject) + if err != nil { + return nil, err + } + + if err := o.tokenRetriever(plain, &token); err != nil { + return nil, err + } + } else { + if err := settings.Json(o.subject, &token); err != nil { + return nil, err + } } } - o.TokenSource = oauth.RefreshTokenSource(&tok, o) + o.TokenSource = oauth.RefreshTokenSource(&token, o) // add instance addInstance(o.subject, o) @@ -108,69 +151,116 @@ func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oaut return o, nil } -// RefreshToken implements oauth.RefreshTokenSource. +// RefreshToken implements oauth.TokenRefresher. func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { if token.RefreshToken == "" { return nil, api.ErrMissingToken } - // log token before refresh o.log.DEBUG.Printf("refreshing token for %s", o.subject) // refresh token source token, err := o.oc.TokenSource(o.ctx, token).Token() if err != nil { - if strings.Contains(err.Error(), "invalid_grant") { - if settings.Exists(o.subject) { - settings.Delete(o.subject) - } + if strings.Contains(err.Error(), "invalid_grant") && settings.Exists(o.subject) { + settings.Delete(o.subject) } + return nil, err } + err = settings.SetJson(o.subject, token) return token, err } +// updateToken must only be called when lock is held +func (o *OAuth) updateToken(token *oauth2.Token) { + var store any = token + + // tokenStorer allows persisting the token together with it's extra properties + if o.tokenStorer != nil { + store = o.tokenStorer(token) + } + + if err := settings.SetJson(o.subject, store); err != nil { + o.log.ERROR.Printf("error saving token: %v", err) + } + + o.TokenSource = oauth.RefreshTokenSource(token, o) +} + // HandleCallback implements api.AuthProvider. -func (o *OAuth) HandleCallback(responseValues url.Values) error { - code := responseValues.Get("code") +func (o *OAuth) HandleCallback(params url.Values) error { + code := params.Get("code") o.mu.Lock() defer o.mu.Unlock() token, err := o.oc.Exchange(o.ctx, code, oauth2.VerifierOption(o.cv)) if err != nil { - o.log.ERROR.Printf("error during oauth exchange: %s", err) return err } - if err := settings.SetJson(o.subject, token); err != nil { - o.log.ERROR.Printf("error saving token: %s", err) - } + o.updateToken(token) - o.TokenSource = oauth.RefreshTokenSource(token, o) return nil } // Login implements api.AuthProvider. -func (o *OAuth) Login(state string) string { +func (o *OAuth) Login(state string) (string, error) { o.mu.Lock() defer o.mu.Unlock() o.cv = oauth2.GenerateVerifier() - return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)) + + if o.deviceFlow { + ctx := context.Background() + + da, err := o.oc.DeviceAuth(ctx, oauth2.S256ChallengeOption(o.cv)) + if err != nil { + return "", err + } + + go func() { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + token, err := o.oc.DeviceAccessToken(ctx, da, oauth2.VerifierOption(o.cv)) + if err != nil { + o.log.ERROR.Printf("error retrieving token: %v", err) + return + } + + o.mu.Lock() + defer o.mu.Unlock() + + o.updateToken(token) + }() + + return da.VerificationURIComplete, nil + } + + if o.oc.Endpoint.AuthURL == "" { + return "", errors.New("missing auth url") + } + + return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)), nil } // Logout implements api.AuthProvider. func (o *OAuth) Logout() error { - o.log.INFO.Printf("removing %s from database", o.subject) + o.log.DEBUG.Printf("removing %s from database", o.subject) + if settings.Exists(o.subject) { - settings.Delete(o.subject) + if err := settings.Delete(o.subject); err != nil { + o.log.ERROR.Println(err) + } } o.mu.Lock() defer o.mu.Unlock() + o.TokenSource = oauth.RefreshTokenSource(nil, o) return nil } @@ -182,6 +272,6 @@ func (o *OAuth) DisplayName() string { // Authenticated implements api.AuthProvider. func (o *OAuth) Authenticated() bool { - token, err := o.TokenSource.Token() + token, err := o.Token() return err == nil && token.Valid() } diff --git a/server/providerauth/handler.go b/server/providerauth/handler.go index 73db32479..cf6a6bf0b 100644 --- a/server/providerauth/handler.go +++ b/server/providerauth/handler.go @@ -69,7 +69,7 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { provider, ok := a.providers[id] if !ok { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "invalid id") + fmt.Fprintln(w, "invalid id") return } @@ -85,11 +85,18 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { delete(a.states, encryptedState) }) + uri, err := provider.Login(encryptedState) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "error: %v", err) + return + } + // return authorization URL res := struct { LoginUri string `json:"loginUri"` }{ - LoginUri: provider.Login(encryptedState), + LoginUri: uri, } if err := json.NewEncoder(w).Encode(res); err != nil { @@ -109,7 +116,7 @@ func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { provider, ok := a.providers[id] if !ok { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "invalid id") + fmt.Fprintln(w, "invalid id") return } @@ -161,9 +168,9 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { // Handle the callback if err := provider.HandleCallback(r.URL.Query()); err != nil { - a.log.ERROR.Printf("callback handling for provider %s failed: %v", id, err) + a.log.ERROR.Printf("callback for provider %s failed: %v", id, err) w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, "callback handling failed") + fmt.Fprintln(w, "callback failed") return } diff --git a/templates/definition/vehicle/cardata.yaml b/templates/definition/vehicle/cardata.yaml new file mode 100644 index 000000000..50e4f579c --- /dev/null +++ b/templates/definition/vehicle/cardata.yaml @@ -0,0 +1,26 @@ +template: cardata +products: + - brand: BMW + description: + generic: CarData (EU Data Act) + - brand: Mini + description: + generic: CarData (EU Data Act) +requirements: + description: + de: | + Benötigt CarData Einrichtung im BMW/Mini portal. + en: | + Requires CarData activation in BMW/Mini portal. +params: + - preset: vehicle-common + - name: vin + example: WBMW... + - name: clientid + - preset: vehicle-features +render: | + type: cardata + vin: {{ .vin }} + clientid: {{ .clientid }} + {{ include "vehicle-common" . }} + {{ include "vehicle-features" . }} diff --git a/vehicle/bmw.go b/vehicle/bmw.go index fe6958f48..159682a33 100644 --- a/vehicle/bmw.go +++ b/vehicle/bmw.go @@ -5,7 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/vehicle/bmw" + bmw "github.com/evcc-io/evcc/vehicle/bmw/connected" ) // BMW is an api.Vehicle implementation for BMW and Mini cars diff --git a/vehicle/bmw/cardata/api.go b/vehicle/bmw/cardata/api.go new file mode 100644 index 000000000..dd8af5ee4 --- /dev/null +++ b/vehicle/bmw/cardata/api.go @@ -0,0 +1,150 @@ +package cardata + +import ( + "fmt" + "net/http" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "github.com/samber/lo" + "golang.org/x/oauth2" +) + +const ApiURL = "https://api-cardata.bmwgroup.com" + +var Config = oauth2.Config{ + Scopes: []string{"authenticate_user", "openid", "cardata:streaming:read", "cardata:api:read"}, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: "https://customer.bmwgroup.com/gcdm/oauth/device/code", + TokenURL: "https://customer.bmwgroup.com/gcdm/oauth/token", + }, +} + +// requiredKeys are the necessary data dictionary entities according to +// https://mybmwweb-utilities.api.bmw/de-de/utilities/bmw/api/cd/catalogue/file +var requiredKeys = []string{ + "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.vehicle.travelledDistance", +} + +type API struct { + *request.Helper +} + +func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API { + v := &API{ + Helper: request.NewHelper(log), + } + + v.Transport = &oauth2.Transport{ + Source: ts, + Base: &transport.Decorator{ + Decorator: transport.DecorateHeaders(map[string]string{ + "x-version": "v1", + }), + Base: v.Transport, + }, + } + + return v +} + +func (v *API) Vehicles() ([]string, error) { + var res []VehicleMapping + err := v.GetJSON(ApiURL+"/customers/vehicles/mappings", &res) + + return lo.Map( + lo.Filter(res, func(v VehicleMapping, _ int) bool { + return v.MappingType == "PRIMARY" + }), func(v VehicleMapping, _ int) string { + return v.Vin + }, + ), err +} + +func (v *API) GetContainers() ([]Container, error) { + var res struct { + Containers []Container + } + if err := v.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 (v *API) CreateContainer() error { + data := CreateContainer{ + Name: "evcc.io", + Purpose: "evcc.io", + TechnicalDescriptors: requiredKeys, + } + + var res any + req, _ := request.New(http.MethodPost, ApiURL+"/customers/containers", request.MarshalJSON(data)) + return v.DoJSON(req, &res) +} + +// func (v *API) DeleteContainer() error { +// if *deleteContainer && len(containers) == 1 { +// req, _ := request.New(http.MethodDelete, apiUrl+"/customers/containers/"+containers[0].ContainerId, nil) + +// var res any +// if err := client.DoJSON(req, &res); err != nil { +// return err +// } + +// containers = nil +// } +// } + +func (v *API) EnsureContainer() (string, error) { + containers, err := v.GetContainers() + if err != nil { + return "", err + } + + if len(containers) > 0 { + return containers[0].ContainerId, nil + } + + 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.vehicle.travelledDistance", + }, + } + + var res Container + + req, _ := request.New(http.MethodPost, ApiURL+"/customers/containers", request.MarshalJSON(data)) + err = v.DoJSON(req, &res) + + return res.ContainerId, err +} + +func (v *API) GetTelematics(vin, container string) (TelematicData, error) { + var res TelematicData + uri := fmt.Sprintf(ApiURL+"/customers/vehicles/%s/telematicData?containerId=%s", vin, container) + err := v.GetJSON(uri, &res) + return res, err +} diff --git a/vehicle/bmw/cardata/provider.go b/vehicle/bmw/cardata/provider.go new file mode 100644 index 000000000..8727d4725 --- /dev/null +++ b/vehicle/bmw/cardata/provider.go @@ -0,0 +1,246 @@ +package cardata + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/spf13/cast" + "golang.org/x/oauth2" +) + +const StreamingURL = "tls://customer.streaming-cardata.bmwgroup.com:9000" + +// Provider implements the vehicle api +type Provider struct { + mu sync.Mutex + log *util.Logger + api *API + ts oauth2.TokenSource + + vin string + + initial map[string]TelematicDataPoint + streaming map[string]StreamingData +} + +// NewProvider creates a vehicle api provider +func NewProvider(log *util.Logger, api *API, ts oauth2.TokenSource, vin string) *Provider { + v := &Provider{ + log: log, + api: api, + ts: ts, + vin: vin, + streaming: make(map[string]StreamingData), + } + + go func() { + bo := backoff.NewExponentialBackOff(backoff.WithMaxInterval(time.Minute)) + + for { + token, err := ts.Token() + if err != nil { + if !tokenError(err) { + v.log.ERROR.Println(err) + } + + time.Sleep(bo.NextBackOff()) + } + + bo.Reset() + + if err := v.runMqtt(vin, token); err != nil { + v.log.ERROR.Println(err) + } + } + }() + + return v +} + +func (v *Provider) runMqtt(vin string, token *oauth2.Token) error { + gcid := TokenExtra(token, "gcid") + idToken := TokenExtra(token, "id_token") + + paho := mqtt.NewClient( + mqtt.NewClientOptions(). + AddBroker(StreamingURL). + SetAutoReconnect(true). + SetUsername(gcid). + SetPassword(idToken)) + + timeout := 30 * time.Second + if t := paho.Connect(); !t.WaitTimeout(timeout) { + return errors.New("connect timeout") + } else if err := t.Error(); err != nil { + return fmt.Errorf("connect: %w", err) + } + defer paho.Disconnect(0) + + topic := fmt.Sprintf("%s/%s", gcid, vin) + + if t := paho.Subscribe(topic, 0, v.handler); !t.WaitTimeout(timeout) { + return errors.New("subcribe timeout") + } else if err := t.Error(); err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + time.Sleep(time.Until(token.Expiry)) + + return nil +} + +func (v *Provider) handler(c mqtt.Client, m mqtt.Message) { + var res StreamingMessage + if err := json.Unmarshal(m.Payload(), &res); err != nil { + v.log.ERROR.Println(m.Topic(), string(m.Payload()), err) + return + } + + v.mu.Lock() + defer v.mu.Unlock() + + maps.Copy(v.streaming, res.Data) +} + +func (v *Provider) any(key string) (any, error) { + v.mu.Lock() + defer v.mu.Unlock() + + if a, ok := v.streaming[key]; ok { + return a, nil + } + + if v.initial == nil { + // don't try as long as there's no token + if _, err := v.ts.Token(); err != nil { + return nil, api.ErrNotAvailable + } + + defer func() { + if v.initial == nil { + v.initial = make(map[string]TelematicDataPoint) + } + }() + + container, err := v.api.EnsureContainer() + if err != nil { + v.log.ERROR.Printf("get container: %v", err) + return nil, api.ErrNotAvailable + } + + if res, err := v.api.GetTelematics(v.vin, container); err == nil { + v.initial = res.TelematicData + } else { + v.log.ERROR.Printf("get telematics: %v", err) + return nil, api.ErrNotAvailable + } + } + + if el, ok := v.initial[key]; ok { + return el.Value, nil + } + + return nil, api.ErrNotAvailable +} + +func (v *Provider) String(key string) (string, error) { + res, err := v.any(key) + if err != nil { + return "", err + } + + return cast.ToStringE(res) +} + +func (v *Provider) Int(key string) (int64, error) { + res, err := v.any(key) + if err != nil { + return 0, err + } + + return cast.ToInt64E(res) +} + +func (v *Provider) Float(key string) (float64, error) { + res, err := v.any(key) + if err != nil { + return 0, err + } + + return cast.ToFloat64E(res) +} + +var _ api.Battery = (*Provider)(nil) + +// Soc implements the api.Vehicle interface +func (v *Provider) Soc() (float64, error) { + return v.Float("vehicle.drivetrain.electricEngine.charging.level") +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + port, err := v.String("vehicle.body.chargingPort.status") + if err != nil { + return api.StatusNone, err + } + + status := api.StatusA // disconnected + if port == "CONNECTED" { + status = api.StatusB + } + + hv, err := v.String("vehicle.drivetrain.electricEngine.charging.hvStatus") + if hv == "CHARGING" { + status = api.StatusC + } + + return status, err +} + +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.Int("vehicle.drivetrain.electricEngine.charging.timeToFullyCharged") + return time.Now().Add(time.Duration(res) * time.Minute), err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + return v.Int("vehicle.drivetrain.electricEngine.kombiRemainingElectricRange") +} + +var _ api.VehicleOdometer = (*Provider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *Provider) Odometer() (float64, error) { + return v.Float("vehicle.vehicle.travelledDistance") +} + +var _ api.SocLimiter = (*Provider)(nil) + +// GetLimitSoc implements the api.SocLimiter interface +func (v *Provider) GetLimitSoc() (int64, error) { + return v.Int("vehicle.powertrain.electric.battery.stateOfCharge.target") +} + +var _ api.VehicleClimater = (*Provider)(nil) + +// Climater implements the api.VehicleClimater interface +func (v *Provider) Climater() (bool, error) { + res, err := v.String("vehicle.cabin.hvac.preconditioning.status.comfortState") + return slices.Contains([]string{"COMFORT_HEATING", "COMFORT_COOLING", "COMFORT_VENTILATION", "DEFROST"}, res), err +} diff --git a/vehicle/bmw/cardata/token.go b/vehicle/bmw/cardata/token.go new file mode 100644 index 000000000..6a8e7a3b0 --- /dev/null +++ b/vehicle/bmw/cardata/token.go @@ -0,0 +1,33 @@ +package cardata + +import ( + "errors" + + "github.com/evcc-io/evcc/api" + "golang.org/x/oauth2" +) + +type Token struct { + *oauth2.Token + IdToken string `json:"id_token"` + Gcid string `json:"gcid"` +} + +func (t *Token) TokenEx() *oauth2.Token { + return t.Token.WithExtra(map[string]any{ + "id_token": t.IdToken, + "gcid": t.Gcid, + }) +} + +// TokenExtra returns extra string properties of the oauth2.Token +func TokenExtra(t *oauth2.Token, key string) string { + if v := t.Extra(key); v != nil { + return v.(string) + } + return "" +} + +func tokenError(err error) bool { + return errors.Is(err, api.ErrLoginRequired) || errors.Is(err, api.ErrMissingToken) +} diff --git a/vehicle/bmw/cardata/types.go b/vehicle/bmw/cardata/types.go new file mode 100644 index 000000000..949153f67 --- /dev/null +++ b/vehicle/bmw/cardata/types.go @@ -0,0 +1,46 @@ +package cardata + +import "time" + +type VehicleMapping struct { + Vin string + MappedSince time.Time + MappingType string +} + +type Container struct { + Name string `json:"name"` + Purpose string `json:"purpose"` + ContainerId string `json:"containerId"` + Created time.Time `json:"created"` +} + +type CreateContainer struct { + Name string `json:"name"` + Purpose string `json:"purpose"` + TechnicalDescriptors []string `json:"technicalDescriptors"` +} + +type TelematicDataPoint struct { + Timestamp time.Time + Unit string + Value string +} + +type TelematicData struct { + TelematicData map[string]TelematicDataPoint +} + +type StreamingMessage struct { + Vin string + EntityId string + Topic string + TimeStamp time.Time + Data map[string]StreamingData +} + +type StreamingData struct { + TimeStamp time.Time + Value any + Unit string +} diff --git a/vehicle/bmw/api.go b/vehicle/bmw/connected/api.go similarity index 100% rename from vehicle/bmw/api.go rename to vehicle/bmw/connected/api.go diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/connected/identity.go similarity index 100% rename from vehicle/bmw/identity.go rename to vehicle/bmw/connected/identity.go diff --git a/vehicle/bmw/param.go b/vehicle/bmw/connected/param.go similarity index 100% rename from vehicle/bmw/param.go rename to vehicle/bmw/connected/param.go diff --git a/vehicle/bmw/provider.go b/vehicle/bmw/connected/provider.go similarity index 100% rename from vehicle/bmw/provider.go rename to vehicle/bmw/connected/provider.go diff --git a/vehicle/bmw/types.go b/vehicle/bmw/connected/types.go similarity index 100% rename from vehicle/bmw/types.go rename to vehicle/bmw/connected/types.go diff --git a/vehicle/cardata.go b/vehicle/cardata.go new file mode 100644 index 000000000..b5f0a9f0f --- /dev/null +++ b/vehicle/cardata.go @@ -0,0 +1,93 @@ +package vehicle + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/bmw/cardata" + "golang.org/x/oauth2" +) + +// Cardata is an api.Vehicle implementation for BMW and Mini cars +type Cardata struct { + *embed + *cardata.Provider // provides the api implementations +} + +func init() { + registry.AddCtx("cardata", NewCardataFromConfig) +} + +// NewCardataFromConfig creates a new BMW/Mini CarData vehicle +func NewCardataFromConfig(ctx context.Context, other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + ClientID, VIN string + Cache time.Duration + }{ + Cache: 30 * time.Minute, // 50 requests per day + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.ClientID == "" { + return nil, api.ErrMissingCredentials + } + + v := &Cardata{ + embed: &cc.embed, + } + + oc := cardata.Config + oc.ClientID = cc.ClientID + + log := util.NewLogger("cardata").Redact(cc.ClientID) + + ts, err := auth.NewOauth(ctx, "cardata", &oc, + auth.WithOauthDeviceFlowOption(), + auth.WithTokenRetrieverOption(func(data string, res *oauth2.Token) error { + var token cardata.Token + if err := json.Unmarshal([]byte(data), &token); err != nil { + return err + } + *res = *token.TokenEx() + return nil + }), + auth.WithTokenStorerOption(func(token *oauth2.Token) any { + return cardata.Token{ + Token: token, + IdToken: cardata.TokenExtra(token, "id_token"), + Gcid: cardata.TokenExtra(token, "gcid"), + } + })) + if err != nil { + return nil, err + } + + api := cardata.NewAPI(log, ts) + + is429 := func(err error) bool { + se := new(request.StatusError) + return errors.As(err, &se) && se.StatusCode() == http.StatusTooManyRequests + } + + vehicle, err := ensureVehicle( + cc.VIN, api.Vehicles, + ) + if err != nil && (cc.VIN == "" || !is429(err)) { + return nil, err + } + + v.Provider = cardata.NewProvider(log, api, ts, vehicle) + + return v, nil +} diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index c31bfc0d0..08cd6ef0c 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -45,7 +45,7 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]interface oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri) ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) - ts, err := auth.NewOauth(ctx, oc, cc.embed.GetTitle()) + ts, err := auth.NewOauth(ctx, cc.embed.GetTitle(), oc) if err != nil { return nil, err }