diff --git a/templates/definition/vehicle/tibber.yaml b/templates/definition/vehicle/tibber.yaml new file mode 100644 index 000000000..fe04770fa --- /dev/null +++ b/templates/definition/vehicle/tibber.yaml @@ -0,0 +1,50 @@ +template: tibber +products: + - brand: Tibber +requirements: + description: + de: | + Liest Fahrzeugdaten (Ladestand, Reichweite) über die offizielle Tibber Data API. + Benötigt einen OAuth2-Client (Client-ID und Secret), der unter https://data-api.tibber.com/clients/manage angelegt wird. + Die unten angezeigte Redirect-URI muss beim Anlegen des Clients hinterlegt werden. + Ein Tibber-Stromvertrag ist nicht erforderlich, das Fahrzeug muss im Tibber-Konto verbunden sein. + en: | + Reads vehicle data (state of charge, range) via the official Tibber Data API. + Requires an OAuth2 client (client id and secret) created at https://data-api.tibber.com/clients/manage. + Register the redirect URI shown below when creating the client. + A Tibber energy contract is not required, the vehicle must be connected in the Tibber account. +params: + - preset: vehicle-common + - name: clientid + required: true + - name: clientsecret + required: true + mask: true + - name: redirecturi + required: true + description: + generic: Redirect URI + help: + en: "Redirect URI of the evcc instance. Must match the redirect URI set in the Tibber Data API client." + de: "Redirect-URI der evcc-Instanz. Muss mit der im Tibber Data API Client hinterlegten Redirect URI übereinstimmen." + service: auth/redirecturi + example: "https://evcc.example.org/providerauth/callback" + - name: vin + example: WVW... + service: tibber/vehicles?clientid={clientid}&clientsecret={clientsecret}&redirecturi={redirecturi} + help: + en: Optional, selects the vehicle when the account has more than one. + de: Optional, wählt das Fahrzeug aus, wenn das Konto mehrere besitzt. + - name: cache + default: 15m +auth: + type: tibber + params: [clientid, clientsecret, redirecturi] +render: | + type: tibber + {{- include "vehicle-common" . }} + clientid: {{ .clientid }} + clientsecret: {{ .clientsecret }} + redirecturi: {{ .redirecturi }} + vin: {{ .vin }} + cache: {{ .cache }} diff --git a/vehicle/tibber.go b/vehicle/tibber.go new file mode 100644 index 000000000..bc59e62aa --- /dev/null +++ b/vehicle/tibber.go @@ -0,0 +1,172 @@ +package vehicle + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/tibber" +) + +// Tibber is an api.Vehicle implementation for the Tibber Data API +type Tibber struct { + *embed + *tibber.API + vin string + homeID string + devID string + dataG func() (tibber.DeviceDetail, error) +} + +func init() { + registry.AddCtx("tibber", NewTibberFromConfig) +} + +// NewTibberFromConfig creates a new vehicle +func NewTibberFromConfig(ctx context.Context, other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + ClientID, ClientSecret, RedirectURI string + VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.ClientID == "" || cc.ClientSecret == "" { + return nil, api.ErrMissingCredentials + } + + log := util.NewLogger("tibber").Redact(cc.ClientID, cc.ClientSecret) + + authCtx := util.WithLogger(context.Background(), log) + ts, err := tibber.NewOAuth(authCtx, cc.ClientID, cc.ClientSecret, cc.RedirectURI, cc.embed.GetTitle()) + if err != nil { + return nil, err + } + + v := &Tibber{ + embed: &cc.embed, + API: tibber.NewAPI(log, ts), + vin: strings.ToUpper(cc.VIN), + } + + v.dataG = util.Cached(v.status, cc.Cache) + + return v, nil +} + +// resolve discovers the home and device id of the configured vehicle. It is +// deferred until first use because authorization happens interactively. With +// only the vehicles scope granted, the devices endpoint returns vehicles only. +func (v *Tibber) resolve() error { + homes, err := v.Homes() + if err != nil { + return err + } + + for _, home := range homes { + devices, err := v.Devices(home.ID) + if err != nil { + return err + } + + for _, d := range devices { + // match the bare vin or the full external id (e.g. tesla:5YJ...) + if v.vin == "" || strings.EqualFold(d.VIN(), v.vin) || strings.EqualFold(d.ExternalID, v.vin) { + v.homeID = home.ID + v.devID = d.ID + return nil + } + } + } + + return fmt.Errorf("vehicle not found: %s", v.vin) +} + +func (v *Tibber) status() (tibber.DeviceDetail, error) { + if v.devID == "" { + if err := v.resolve(); err != nil { + return tibber.DeviceDetail{}, err + } + } + + return v.Device(v.homeID, v.devID) +} + +// Soc implements the api.Vehicle interface +func (v *Tibber) Soc() (float64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + + soc, ok := res.Soc() + if !ok { + return 0, api.ErrNotAvailable + } + + return soc, nil +} + +var _ api.ChargeState = (*Tibber)(nil) + +// Status implements the api.ChargeState interface +func (v *Tibber) Status() (api.ChargeStatus, error) { + res, err := v.dataG() + if err != nil { + return api.StatusNone, err + } + + status := api.StatusA // disconnected + + if plug, ok := res.PlugStatus(); ok && plug == tibber.StatusConnected { + status = api.StatusB // connected, not charging + } + if charging, ok := res.ChargingStatus(); ok && charging == tibber.StatusCharging { + status = api.StatusC // charging + } + + return status, nil +} + +var _ api.SocLimiter = (*Tibber)(nil) + +// GetLimitSoc implements the api.SocLimiter interface +func (v *Tibber) GetLimitSoc() (int64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + + soc, ok := res.TargetSoc() + if !ok { + return 0, api.ErrNotAvailable + } + + return int64(soc), nil +} + +var _ api.VehicleRange = (*Tibber)(nil) + +// Range implements the api.VehicleRange interface +func (v *Tibber) Range() (int64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + + rng, ok := res.Range() + if !ok { + return 0, api.ErrNotAvailable + } + + return int64(rng), nil +} diff --git a/vehicle/tibber/api.go b/vehicle/tibber/api.go new file mode 100644 index 000000000..5c35cc2ce --- /dev/null +++ b/vehicle/tibber/api.go @@ -0,0 +1,209 @@ +package tibber + +import ( + "fmt" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/spf13/cast" + "golang.org/x/oauth2" +) + +// Home is an entry of the customer's home list. +type Home struct { + ID string + Name string +} + +// Device is a device list entry (identity only, no live values). +type Device struct { + ID string + ExternalID string + Info DeviceInfo +} + +// DeviceInfo holds static make/brand/model information. +type DeviceInfo struct { + Name string + Brand string + Model string +} + +// VIN returns the vehicle identification number from the external id, which is +// formatted as vendor:vin (e.g. tesla:5YJSA1E26MF1234567). +func (d Device) VIN() string { + if _, vin, ok := strings.Cut(d.ExternalID, ":"); ok { + return vin + } + return d.ExternalID +} + +// DeviceDetail is the full device state including capabilities. +type DeviceDetail struct { + ID string + ExternalID string + Info DeviceInfo + Capabilities []Capability +} + +// Capability is a single device capability and its last-seen value. The value +// is delivered as a JSON number or string. +type Capability struct { + ID string + Description string + Value any + Unit string +} + +// API is the Tibber Data API REST client. +type API struct { + *request.Helper +} + +// NewAPI creates a Tibber Data API client authenticated via the given token source. +func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API { + client := request.NewHelper(log) + client.Transport = &oauth2.Transport{ + Source: ts, + Base: client.Transport, + } + return &API{Helper: client} +} + +// Homes lists the homes the user has access to. +func (v *API) Homes() ([]Home, error) { + var res struct { + Homes []Home + } + err := v.GetJSON(fmt.Sprintf("%s/homes", ApiURI), &res) + return res.Homes, err +} + +// Devices lists the devices of a home. +func (v *API) Devices(homeID string) ([]Device, error) { + var res struct { + Devices []Device + } + err := v.GetJSON(fmt.Sprintf("%s/homes/%s/devices", ApiURI, homeID), &res) + return res.Devices, err +} + +// Vehicles lists the vehicles across all homes, deduplicated. With only the +// vehicles scope granted, the devices endpoint returns vehicles only. +func (v *API) Vehicles() ([]Device, error) { + homes, err := v.Homes() + if err != nil { + return nil, err + } + + var res []Device + seen := make(map[string]bool) + + for _, home := range homes { + devices, err := v.Devices(home.ID) + if err != nil { + return nil, err + } + + for _, d := range devices { + if !seen[d.ID] { + seen[d.ID] = true + res = append(res, d) + } + } + } + + return res, nil +} + +// Device returns the full state of a single device. +func (v *API) Device(homeID, deviceID string) (DeviceDetail, error) { + var res DeviceDetail + err := v.GetJSON(fmt.Sprintf("%s/homes/%s/devices/%s", ApiURI, homeID, deviceID), &res) + return res, err +} + +// Tibber Data API capability ids and the enum values they report. +const ( + idSoc = "storage.stateOfCharge" // % + idTargetSoc = "storage.targetStateOfCharge" // % + idRange = "range.remaining" // distance, typically m + idConnector = "connector.status" // connected/disconnected/unknown + idCharging = "charging.status" // charging/idle/unknown + + StatusConnected = "connected" // connector.status + StatusCharging = "charging" // charging.status +) + +const kmPerMile = 1.609344 + +// capability returns the capability with the given id. +func (d DeviceDetail) capability(id string) (Capability, bool) { + for _, c := range d.Capabilities { + if c.ID == id { + return c, true + } + } + return Capability{}, false +} + +// Soc returns the battery state of charge in percent. +func (d DeviceDetail) Soc() (float64, bool) { + c, ok := d.capability(idSoc) + if !ok { + return 0, false + } + f, err := cast.ToFloat64E(c.Value) + return f, err == nil +} + +// TargetSoc returns the configured charge limit in percent. +func (d DeviceDetail) TargetSoc() (float64, bool) { + c, ok := d.capability(idTargetSoc) + if !ok { + return 0, false + } + f, err := cast.ToFloat64E(c.Value) + return f, err == nil +} + +// Range returns the estimated range in km, converting m/mi to km as needed. +func (d DeviceDetail) Range() (float64, bool) { + c, ok := d.capability(idRange) + if !ok { + return 0, false + } + f, err := cast.ToFloat64E(c.Value) + if err != nil { + return 0, false + } + switch c.Unit { + case "m": + f /= 1000 + case "mi", "mile", "miles": + f *= kmPerMile + } + return f, true +} + +// PlugStatus returns the connector (plug) status value, e.g. connected, +// disconnected or unknown. +func (d DeviceDetail) PlugStatus() (string, bool) { + return d.statusValue(idConnector) +} + +// ChargingStatus returns the charging status value, e.g. charging, idle or unknown. +func (d DeviceDetail) ChargingStatus() (string, bool) { + return d.statusValue(idCharging) +} + +// statusValue returns the string value of the capability with the given id. +func (d DeviceDetail) statusValue(id string) (string, bool) { + c, ok := d.capability(id) + if !ok { + return "", false + } + s, ok := c.Value.(string) + return s, ok +} diff --git a/vehicle/tibber/oauth.go b/vehicle/tibber/oauth.go new file mode 100644 index 000000000..f019ef2b2 --- /dev/null +++ b/vehicle/tibber/oauth.go @@ -0,0 +1,58 @@ +package tibber + +import ( + "context" + + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" + "golang.org/x/oauth2" +) + +const ( + // AuthURI is the Tibber Data API authorization endpoint. + AuthURI = "https://thewall.tibber.com/connect/authorize" + // TokenURI is the Tibber Data API token endpoint. + TokenURI = "https://thewall.tibber.com/connect/token" + // ApiURI is the Tibber Data API base URL. + ApiURI = "https://data-api.tibber.com/v1" +) + +func init() { + auth.Register("tibber", func(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + ClientID, ClientSecret, RedirectURI string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("tibber").Redact(cc.ClientID, cc.ClientSecret) + ctx := util.WithLogger(context.Background(), log) + + return NewOAuth(ctx, cc.ClientID, cc.ClientSecret, cc.RedirectURI, "") + }) +} + +// OAuthConfig returns the Tibber Data API OAuth2 config. +func OAuthConfig(clientID, clientSecret, redirectURI string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURI, + Endpoint: oauth2.Endpoint{ + AuthURL: AuthURI, + TokenURL: TokenURI, + }, + Scopes: []string{ + "openid", "profile", "email", "offline_access", + "data-api-user-read", "data-api-vehicles-read", + }, + } +} + +// NewOAuth creates the Tibber Data API token source using the authorization +// code flow with PKCE. The user authorizes interactively via the evcc UI. +func NewOAuth(ctx context.Context, clientID, clientSecret, redirectURI, title string) (oauth2.TokenSource, error) { + return auth.NewOAuth(ctx, "Tibber", title, OAuthConfig(clientID, clientSecret, redirectURI)) +} diff --git a/vehicle/tibber/service.go b/vehicle/tibber/service.go new file mode 100644 index 000000000..b5c5fbc2f --- /dev/null +++ b/vehicle/tibber/service.go @@ -0,0 +1,53 @@ +package tibber + +import ( + "context" + "encoding/json" + "net/http" + "slices" + + "github.com/evcc-io/evcc/server/service" + "github.com/evcc-io/evcc/util" +) + +func init() { + mux := http.NewServeMux() + mux.HandleFunc("GET /vehicles", getVehicles) + + service.Register("tibber", mux) +} + +// getVehicles lists the external ids of the vehicles in the account, driving +// vehicle selection in the template. It reuses the OAuth instance created for +// the same client, so results appear once the user has authorized via the UI. +func getVehicles(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + + q := req.URL.Query() + clientID, clientSecret, redirectURI := q.Get("clientid"), q.Get("clientsecret"), q.Get("redirecturi") + + ids := []string{} + defer func() { _ = json.NewEncoder(w).Encode(ids) }() + + if clientID == "" || clientSecret == "" { + return + } + + log := util.NewLogger("tibber").Redact(clientID, clientSecret) + + ts, err := NewOAuth(util.WithLogger(context.Background(), log), clientID, clientSecret, redirectURI, "") + if err != nil { + return + } + + // no values until the user has authorized + vehicles, err := NewAPI(log, ts).Vehicles() + if err != nil { + return + } + + for _, v := range vehicles { + ids = append(ids, v.VIN()) + } + slices.Sort(ids) +}