Vehicle: add Octopus Energy Germany (#31105)

This commit is contained in:
Philipp Sandhaus 2026-06-22 09:52:10 +02:00 • committed by GitHub
parent e78b5948f9
commit 97b249235d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 501 additions and 0 deletions

132
vehicle/octopusde.go Normal file
View file

@ -0,0 +1,132 @@
package vehicle
import (
"fmt"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/octopusde"
)
// OctopusDe is an api.Vehicle implementation for the Octopus Energy Germany Kraken API
type OctopusDe struct {
*embed
*octopusde.API
account string
device string
deviceID string
dataG func() (octopusde.Device, error)
}
func init() {
registry.Add("octopus-de", NewOctopusDeFromConfig)
}
// NewOctopusDeFromConfig creates a new vehicle
func NewOctopusDeFromConfig(other map[string]any) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
Email string
Password string
AccountNumber string
Device string
Cache time.Duration
}{
Cache: interval,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.Email == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
}
log := util.NewLogger("octopus-de").Redact(cc.Email, cc.Password)
api, err := octopusde.NewAPI(log, cc.Email, cc.Password)
if err != nil {
return nil, err
}
v := &OctopusDe{
embed: &cc.embed,
API: api,
account: cc.AccountNumber,
device: cc.Device,
}
v.dataG = util.Cached(v.status, cc.Cache)
return v, nil
}
// status fetches the live state of the configured device, resolving the account
// and matching device on first use.
func (v *OctopusDe) status() (octopusde.Device, error) {
account, err := v.Account(v.account)
if err != nil {
return octopusde.Device{}, err
}
v.account = account
devices, err := v.Devices(account)
if err != nil {
return octopusde.Device{}, err
}
for _, d := range devices {
// match the configured device by id or name, or take the first one
if v.deviceID != "" {
if d.ID == v.deviceID {
return d, nil
}
continue
}
if v.device == "" || strings.EqualFold(d.ID, v.device) || strings.EqualFold(d.Name, v.device) {
v.deviceID = d.ID
v.fromVehicle(d.Name, 0)
return d, nil
}
}
if v.device == "" {
return octopusde.Device{}, api.ErrNotAvailable
}
return octopusde.Device{}, fmt.Errorf("device not found: %s", v.device)
}
// Soc implements the api.Vehicle interface
func (v *OctopusDe) 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.SocLimiter = (*OctopusDe)(nil)
// GetLimitSoc implements the api.SocLimiter interface
func (v *OctopusDe) 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
}

166
vehicle/octopusde/api.go Normal file
View file

@ -0,0 +1,166 @@
package octopusde
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/evcc-io/evcc/api"
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
"github.com/evcc-io/evcc/util"
)
// jsonFloat is a float64 that unmarshals from either a JSON number or a JSON-encoded string.
// The Octopus Kraken API serialises some numeric fields as quoted strings (e.g. "41.00").
type jsonFloat float64
func (f *jsonFloat) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err == nil {
*f = jsonFloat(v)
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
*f = jsonFloat(v)
return nil
}
// API is the Octopus Energy Germany Kraken client for vehicle data. It reuses the
// authenticated Kraken GraphQL client from the tariff implementation so the JWT
// token source and auth transport are not duplicated.
type API struct {
*octoDeGql.OctopusDeGraphQLClient
}
// NewAPI creates a Kraken API client authenticated via the given credentials.
func NewAPI(log *util.Logger, email, password string) (*API, error) {
// the account number is discovered on demand and not needed for the shared client
client, err := octoDeGql.NewClient(log, email, password, "")
if err != nil {
return nil, err
}
return &API{OctopusDeGraphQLClient: client}, nil
}
// krakenAccounts lists the accounts accessible to the authenticated user.
type krakenAccounts struct {
Viewer struct {
Accounts []struct {
Number string
}
}
}
// Account returns the configured account number, or the first account accessible
// to the authenticated user when none is configured.
func (v *API) Account(account string) (string, error) {
if account != "" {
return account, nil
}
accounts, err := v.Accounts()
if err != nil {
return "", err
}
if len(accounts) == 0 {
return "", api.ErrNotAvailable
}
return accounts[0], nil
}
// Accounts returns the account numbers accessible to the authenticated user.
func (v *API) Accounts() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var q krakenAccounts
if err := v.Query(ctx, &q, nil); err != nil {
return nil, err
}
res := make([]string, 0, len(q.Viewer.Accounts))
for _, a := range q.Viewer.Accounts {
res = append(res, a.Number)
}
return res, nil
}
// socStatus holds the live state-of-charge values reported for a SmartFlex device.
// Vehicles and charge points expose the same shape via distinct interface types.
// Pointers distinguish an absent value from a reported zero.
type socStatus struct {
StateOfCharge struct {
Value *jsonFloat
}
StateOfChargeLimit struct {
UpperSocLimit *jsonFloat
}
}
// Device is a SmartFlex device (vehicle or charge point) with its live status.
type Device struct {
ID string
Name string
DeviceType string
Provider string
Status struct {
// Both fragments select the same fields; the API returns whichever matches
// the device's concrete type, so reading either yields the live values.
Vehicle socStatus `graphql:"... on SmartFlexVehicleStatus"`
ChargePoint socStatus `graphql:"... on SmartFlexChargePointStatus"`
}
}
// soc returns the populated state-of-charge status for the device.
func (d Device) soc() socStatus {
if d.Status.Vehicle.StateOfCharge.Value != nil || d.Status.Vehicle.StateOfChargeLimit.UpperSocLimit != nil {
return d.Status.Vehicle
}
return d.Status.ChargePoint
}
// Soc returns the battery state of charge in percent, if reported.
func (d Device) Soc() (float64, bool) {
soc := d.soc().StateOfCharge.Value
if soc == nil {
return 0, false
}
return float64(*soc), true
}
// TargetSoc returns the configured charge limit in percent, if any.
func (d Device) TargetSoc() (float64, bool) {
limit := d.soc().StateOfChargeLimit.UpperSocLimit
if limit == nil {
return 0, false
}
return float64(*limit), true
}
// krakenDevices lists the SmartFlex devices of an account.
type krakenDevices struct {
Devices []Device `graphql:"devices(accountNumber: $accountNumber)"`
}
// Devices lists the SmartFlex devices of the given account.
func (v *API) Devices(accountNumber string) ([]Device, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var q krakenDevices
if err := v.Query(ctx, &q, map[string]any{
"accountNumber": accountNumber,
}); err != nil {
return nil, err
}
return q.Devices, nil
}

View file

@ -0,0 +1,90 @@
package octopusde
import (
"testing"
"github.com/hasura/go-graphql-client/pkg/jsonutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDecodeDevices verifies the GraphQL response decodes through the same
// unmarshaller the client uses, covering the SmartFlex status inline fragments.
func TestDecodeDevices(t *testing.T) {
data := []byte(`{
"devices": [
{
"id": "dev-1",
"name": "My Car",
"deviceType": "ELECTRIC_VEHICLES",
"provider": "TESLA",
"status": {
"stateOfCharge": {"value": 55},
"stateOfChargeLimit": {"upperSocLimit": 80}
}
},
{
"id": "dev-2",
"name": "Wallbox",
"deviceType": "CHARGE_POINTS",
"provider": "OCPP",
"status": {
"stateOfCharge": {"value": 42}
}
},
{
"id": "dev-3",
"name": "No Soc",
"deviceType": "CHARGE_POINTS",
"provider": "OCPP",
"status": {}
},
{
"id": "dev-4",
"name": "String Soc",
"deviceType": "ELECTRIC_VEHICLES",
"provider": "VOLKSWAGEN",
"status": {
"stateOfCharge": {"value": "41.00"},
"stateOfChargeLimit": null
}
}
]
}`)
var q krakenDevices
require.NoError(t, jsonutil.UnmarshalGraphQL(data, &q))
require.Len(t, q.Devices, 4)
// vehicle with soc and target limit (numeric values)
veh := q.Devices[0]
assert.Equal(t, "dev-1", veh.ID)
assert.Equal(t, "My Car", veh.Name)
soc, ok := veh.Soc()
assert.True(t, ok)
assert.Equal(t, float64(55), soc)
limit, ok := veh.TargetSoc()
assert.True(t, ok)
assert.Equal(t, float64(80), limit)
// charge point with soc but no target limit
cp := q.Devices[1]
soc, ok = cp.Soc()
assert.True(t, ok)
assert.Equal(t, float64(42), soc)
_, ok = cp.TargetSoc()
assert.False(t, ok)
// device without any state of charge
none := q.Devices[2]
_, ok = none.Soc()
assert.False(t, ok)
// vehicle with string-encoded soc (real Kraken API behaviour)
strVeh := q.Devices[3]
soc, ok = strVeh.Soc()
assert.True(t, ok)
assert.Equal(t, 41.0, soc)
_, ok = strVeh.TargetSoc()
assert.False(t, ok)
}

View file

@ -0,0 +1,57 @@
package octopusde
import (
"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 /devices", getDevices)
service.Register("octopus-de", mux)
}
// getDevices lists the ids of the SmartFlex devices in the account, driving
// device selection in the template.
func getDevices(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
q := req.URL.Query()
email, password, account := q.Get("email"), q.Get("password"), q.Get("accountnumber")
ids := []string{}
defer func() { _ = json.NewEncoder(w).Encode(ids) }()
if email == "" || password == "" {
return
}
log := util.NewLogger("octopus-de").Redact(email, password)
api, err := NewAPI(log, email, password)
if err != nil {
log.ERROR.Println(err)
return
}
account, err = api.Account(account)
if err != nil {
log.ERROR.Println(err)
return
}
devices, err := api.Devices(account)
if err != nil {
log.ERROR.Println(err)
return
}
for _, d := range devices {
ids = append(ids, d.ID)
}
slices.Sort(ids)
}