Tibber: add vehicle via Data API (#30487)

This commit is contained in:
andig 2026-06-04 12:12:00 +02:00 • committed by GitHub
parent 08b9dbbfc1
commit ffbdb4eac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 542 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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)
}