Tibber: add vehicle via Data API (#30487)
This commit is contained in:
parent
08b9dbbfc1
commit
ffbdb4eac3
5 changed files with 542 additions and 0 deletions
50
templates/definition/vehicle/tibber.yaml
Normal file
50
templates/definition/vehicle/tibber.yaml
Normal file
|
|
@ -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 }}
|
||||
172
vehicle/tibber.go
Normal file
172
vehicle/tibber.go
Normal file
|
|
@ -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
|
||||
}
|
||||
209
vehicle/tibber/api.go
Normal file
209
vehicle/tibber/api.go
Normal file
|
|
@ -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
|
||||
}
|
||||
58
vehicle/tibber/oauth.go
Normal file
58
vehicle/tibber/oauth.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
53
vehicle/tibber/service.go
Normal file
53
vehicle/tibber/service.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue