Vehicle: add Octopus Energy Germany (#31105)
This commit is contained in:
parent
e78b5948f9
commit
97b249235d
6 changed files with 501 additions and 0 deletions
51
templates/definition/vehicle/octopus-de.yaml
Normal file
51
templates/definition/vehicle/octopus-de.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
template: octopus-de
|
||||
products:
|
||||
- brand: Octopus Energy
|
||||
description:
|
||||
de: Deutschland
|
||||
en: Germany
|
||||
countries: ["DE"]
|
||||
group: generic
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
description:
|
||||
de: |
|
||||
Liest Fahrzeugdaten (Ladestand, Zielladestand) über die Octopus Energy Germany Kraken API.
|
||||
Das Fahrzeug bzw. die Wallbox muss im Octopus-Konto (Intelligent Octopus) verbunden sein.
|
||||
en: |
|
||||
Reads vehicle data (state of charge, target soc) via the Octopus Energy Germany Kraken API.
|
||||
The vehicle or charge point must be connected in the Octopus account (Intelligent Octopus).
|
||||
params:
|
||||
- preset: vehicle-common
|
||||
- preset: vehicle-climater
|
||||
- name: email
|
||||
required: true
|
||||
- name: password
|
||||
required: true
|
||||
- name: accountnumber
|
||||
example: "A-12345678"
|
||||
description:
|
||||
en: Account Number
|
||||
de: Kundennummer
|
||||
help:
|
||||
en: Account when several are linked (e.g. A-12345678).
|
||||
de: Konto, wenn mehrere verknüpft sind (z.B. A-12345678).
|
||||
- name: device
|
||||
description:
|
||||
en: Vehicle
|
||||
de: Fahrzeug
|
||||
service: octopus-de/devices?email={email}&password={password}&accountnumber={accountnumber}
|
||||
help:
|
||||
en: Vehicle when the account has more than one.
|
||||
de: Fahrzeug, wenn das Konto mehrere besitzt.
|
||||
- name: cache
|
||||
default: 15m
|
||||
render: |
|
||||
type: octopus-de
|
||||
{{- include "vehicle-common" . }}
|
||||
email: {{ .email }}
|
||||
password: {{ .password }}
|
||||
accountnumber: {{ .accountnumber }}
|
||||
device: {{ .device }}
|
||||
cache: {{ .cache }}
|
||||
{{ include "vehicle-features" . }}
|
||||
|
|
@ -62,6 +62,11 @@ params:
|
|||
de: Zugangspasswort des Dienstes
|
||||
en: Service account password
|
||||
mask: true
|
||||
- name: email
|
||||
description:
|
||||
de: E-Mail-Adresse
|
||||
en: Email address
|
||||
example: "user@example.com"
|
||||
- name: capacity
|
||||
unit: kWh
|
||||
description:
|
||||
|
|
|
|||
132
vehicle/octopusde.go
Normal file
132
vehicle/octopusde.go
Normal 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
166
vehicle/octopusde/api.go
Normal 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
|
||||
}
|
||||
90
vehicle/octopusde/api_test.go
Normal file
90
vehicle/octopusde/api_test.go
Normal 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)
|
||||
}
|
||||
57
vehicle/octopusde/service.go
Normal file
57
vehicle/octopusde/service.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue