Repository: andig/evcc
Pull request #504 (2020-12-05 13:25:54)

Request battery refresh when cache is expired
Fix https://github.com/andig/evcc/issues/488


Patch:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index aa307191..8c0937b6 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"math"
 	"sort"
+	"strings"
 	"sync"
 	"time"
 
@@ -29,15 +30,31 @@ const (
 	minActiveCurrent = 1.0 // minimum current at which a phase is treated as active
 )
 
+// PollConfig defines the vehicle polling mode and interval
+type PollConfig struct {
+	Mode     string        `mapstructure:"mode"`     // polling mode charging (default), connected, always
+	Interval time.Duration `mapstructure:"interval"` // interval when not charging
+}
+
 // SoCConfig defines soc settings, estimation and update behaviour
 type SoCConfig struct {
-	AlwaysUpdate bool  `mapstructure:"alwaysUpdate"`
-	Levels       []int `mapstructure:"levels"`
-	Estimate     bool  `mapstructure:"estimate"`
-	Min          int   `mapstructure:"min"`    // Default minimum SoC, guarded by mutex
-	Target       int   `mapstructure:"target"` // Default target SoC, guarded by mutex
+	Poll         PollConfig `mapstructure:"poll"`
+	AlwaysUpdate bool       `mapstructure:"alwaysUpdate"`
+	Levels       []int      `mapstructure:"levels"`
+	Estimate     bool       `mapstructure:"estimate"`
+	Min          int        `mapstructure:"min"`    // Default minimum SoC, guarded by mutex
+	Target       int        `mapstructure:"target"` // Default target SoC, guarded by mutex
 }
 
+// Poll modes
+const (
+	pollCharging  = "charging"
+	pollConnected = "connected"
+	pollAlways    = "always"
+
+	pollInterval = 60 * time.Minute
+)
+
 // ThresholdConfig defines enable/disable hysteresis parameters
 type ThresholdConfig struct {
 	Delay     time.Duration
@@ -80,6 +97,7 @@ type LoadPoint struct {
 	enabled      bool      // Charger enabled state
 	maxCurrent   float64   // Charger current limit
 	guardUpdated time.Time // Charger enabled/disabled timestamp
+	socUpdated   time.Time // SoC updated timestamp (poll: connected)
 
 	charger     api.Charger
 	chargeTimer api.ChargeTimer
@@ -115,6 +133,32 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
 	lp.OnDisconnect.Mode = api.ChargeModeString(string(lp.OnDisconnect.Mode))
 
 	sort.Ints(lp.SoC.Levels)
+
+	// set vehicle polling mode
+	switch lp.SoC.Poll.Mode = strings.ToLower(lp.SoC.Poll.Mode); lp.SoC.Poll.Mode {
+	case pollCharging:
+	case pollConnected, pollAlways:
+		log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.SoC.Poll)
+	default:
+		if lp.SoC.Poll.Mode != "" {
+			log.WARN.Printf("invalid poll mode: %s", lp.SoC.Poll.Mode)
+		}
+		if lp.SoC.AlwaysUpdate {
+			log.WARN.Println("alwaysUpdate is deprecated and will be removed in a future release. Use poll instead.")
+		} else {
+			lp.SoC.Poll.Mode = pollConnected
+		}
+	}
+
+	// set vehicle polling interval
+	if lp.SoC.Poll.Interval < pollInterval {
+		if lp.SoC.Poll.Interval == 0 {
+			lp.SoC.Poll.Interval = pollInterval
+		} else {
+			log.WARN.Printf("poll interval '%v' is lower than %v and may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.SoC.Poll.Interval, pollInterval)
+		}
+	}
+
 	if lp.SoC.Target == 0 {
 		lp.SoC.Target = lp.OnDisconnect.TargetSoC // use disconnect value as default soc
 		if lp.SoC.Target == 0 {
@@ -237,12 +281,18 @@ func (lp *LoadPoint) publish(key string, val interface{}) {
 func (lp *LoadPoint) evChargeStartHandler() {
 	lp.log.INFO.Println("start charging ->")
 	lp.triggerEvent(evChargeStart)
+
+	// soc estimation reset
+	lp.socUpdated = time.Time{}
 }
 
 // evChargeStopHandler sends external stop event
 func (lp *LoadPoint) evChargeStopHandler() {
 	lp.log.INFO.Println("stop charging <-")
 	lp.triggerEvent(evChargeStop)
+
+	// soc estimation reset
+	lp.socUpdated = time.Time{}
 }
 
 // evVehicleConnectHandler sends external start event
@@ -257,6 +307,9 @@ func (lp *LoadPoint) evVehicleConnectHandler() {
 	lp.connectedTime = lp.clock.Now()
 	lp.publish("connectedDuration", 0)
 
+	// soc estimation reset
+	lp.socUpdated = time.Time{}
+
 	// soc estimation reset on car change
 	if lp.socEstimator != nil {
 		lp.socEstimator.Reset()
@@ -282,6 +335,9 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 	if lp.OnDisconnect.TargetSoC != 0 {
 		_ = lp.SetTargetSoC(lp.OnDisconnect.TargetSoC)
 	}
+
+	// soc estimation reset
+	lp.socUpdated = time.Time{}
 }
 
 // evChargeCurrentHandler publishes the charge current
@@ -712,13 +768,31 @@ func (lp *LoadPoint) publishChargeProgress() {
 	lp.publish("chargeDuration", lp.chargeDuration)
 }
 
-// publish state of charge and remaining charge duration
-func (lp *LoadPoint) publishSoC() {
+// socPollAllowed validates charging state against polling mode
+func (lp *LoadPoint) socPollAllowed() bool {
+	remaining := lp.SoC.Poll.Interval - lp.clock.Since(lp.socUpdated)
+	updateAllowed := lp.socUpdated.IsZero() || remaining <= 0
+
+	honourUpdateInterval := lp.SoC.Poll.Mode == pollAlways ||
+		lp.SoC.Poll.Mode == pollConnected && lp.connected()
+
+	if honourUpdateInterval && remaining > 0 {
+		lp.log.DEBUG.Printf("next soc poll remaining time: %v", remaining.Truncate(time.Second))
+	}
+
+	return lp.charging || honourUpdateInterval && updateAllowed
+}
+
+// publish state of charge, remaining charge duration and range
+func (lp *LoadPoint) publishSoCAndRange() {
 	if lp.socEstimator == nil {
 		return
 	}
 
-	if lp.SoC.AlwaysUpdate || lp.connected() {
+	if lp.socPollAllowed() {
+		lp.socUpdated = lp.clock.Now()
+
+		// soc
 		f, err := lp.socEstimator.SoC(lp.chargedEnergy)
 		if err == nil {
 			lp.socCharge = math.Trunc(f)
@@ -733,29 +807,29 @@ func (lp *LoadPoint) publishSoC() {
 
 			chargeRemainingEnergy := 1e3 * lp.socEstimator.RemainingChargeEnergy(lp.SoC.Target)
 			lp.publish("chargeRemainingEnergy", chargeRemainingEnergy)
+		} else {
+			lp.log.ERROR.Printf("vehicle error: %v", err)
+		}
 
-			return
+		// range
+		if vs, ok := lp.vehicle.(api.VehicleRange); ok {
+			if rng, err := vs.Range(); err == nil {
+				lp.log.DEBUG.Printf("vehicle range: %vkm", rng)
+				lp.publish("range", rng)
+			}
 		}
 
-		lp.log.ERROR.Printf("vehicle error: %v", err)
+		return
 	}
 
-	lp.publish("socCharge", -1)
-	lp.publish("chargeEstimate", time.Duration(-1))
-}
-
-// publish remaining vehicle range
-func (lp *LoadPoint) publishRange() {
-	if vs, ok := lp.vehicle.(api.VehicleRange); ok {
-		if rng, err := vs.Range(); err == nil {
-			lp.log.DEBUG.Printf("vehicle range: %vkm", rng)
-			lp.publish("range", rng)
+	// reset if poll: connected/charging and not connected
+	if lp.SoC.Poll.Mode != pollAlways && !lp.connected() {
+		lp.publish("socCharge", -1)
+		lp.publish("chargeEstimate", time.Duration(-1))
 
-			return
-		}
+		// range
+		lp.publish("range", -1)
 	}
-
-	lp.publish("range", -1)
 }
 
 // Update is the main control function. It reevaluates meters and charger state
@@ -786,8 +860,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
 	// must be run after updating charger status to make sure
 	// initial update of connected state matches charger status
 	lp.findActiveVehicle()
-	lp.publishSoC()
-	lp.publishRange()
+	lp.publishSoCAndRange()
 
 	// sync settings with charger
 	lp.syncCharger()
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 2a1eef55..fb6de370 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -71,9 +71,19 @@ loadpoints:
   # - e-Up
   mode: pv
   soc:
+    # polling defines usage of the vehicle APIs
+    # Modifying the default settings it NOT recommended. It MAY deplete your vehicle's battery
+    # or lead to vehicle manufacturer banning you from API use. USE AT YOUR OWN RISK.
+    poll:
+      # poll mode defines under which condition the vehicle API is called:
+      #   charging: update vehicle ONLY when charging (this is the recommended default)
+      #   connected: update vehicle when connected (not only charging), interval defines how often
+      #   always: always update vehicle regardless of connection state, interval defines how often
+      mode: charging
+      # poll interval defines how often the vehicle API may be polled if NOT charging
+      interval: 60m
     min: 0 # immediately charge to 0% regardless of mode unless "off" (disabled)
     target: 100 # always charge to 100%
-    alwaysUpdate: false # set true to update vehicle soc even when disconnected
     estimate: false # set true to interpolate between api updates
     levels: # target soc levels for UI
     - 30
diff --git a/vehicle/audi.go b/vehicle/audi.go
index 51646b2b..524cc0f9 100644
--- a/vehicle/audi.go
+++ b/vehicle/audi.go
@@ -41,7 +41,10 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity            int64
 		User, Password, VIN string
 		Cache               time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
+
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
diff --git a/vehicle/bmw.go b/vehicle/bmw.go
index 34100796..b68757bd 100644
--- a/vehicle/bmw.go
+++ b/vehicle/bmw.go
@@ -51,7 +51,9 @@ func NewBMWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity            int64
 		User, Password, VIN string
 		Cache               time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
diff --git a/vehicle/carwings.go b/vehicle/carwings.go
index 5b312c69..0b471ab4 100644
--- a/vehicle/carwings.go
+++ b/vehicle/carwings.go
@@ -30,6 +30,7 @@ func NewCarWingsFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Cache                  time.Duration
 	}{
 		Region: carwings.RegionEurope,
+		Cache:  interval,
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
diff --git a/vehicle/config.go b/vehicle/config.go
index a2312340..fab53330 100644
--- a/vehicle/config.go
+++ b/vehicle/config.go
@@ -3,10 +3,13 @@ package vehicle
 import (
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/andig/evcc/api"
 )
 
+const interval = 15 * time.Minute
+
 type vehicleRegistry map[string]func(map[string]interface{}) (api.Vehicle, error)
 
 func (r vehicleRegistry) Add(name string, factory func(map[string]interface{}) (api.Vehicle, error)) {
diff --git a/vehicle/ford.go b/vehicle/ford.go
index 910d8720..9af79a46 100644
--- a/vehicle/ford.go
+++ b/vehicle/ford.go
@@ -39,7 +39,9 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity            int64
 		User, Password, VIN string
 		Cache               time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
diff --git a/vehicle/hyundai.go b/vehicle/hyundai.go
index f439091f..58be9414 100644
--- a/vehicle/hyundai.go
+++ b/vehicle/hyundai.go
@@ -25,7 +25,9 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity       int64
 		User, Password string
 		Cache          time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
diff --git a/vehicle/kia.go b/vehicle/kia.go
index 9ad64aee..a5390fda 100644
--- a/vehicle/kia.go
+++ b/vehicle/kia.go
@@ -25,7 +25,9 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity       int64
 		User, Password string
 		Cache          time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
diff --git a/vehicle/nissan.go b/vehicle/nissan.go
index f9ac8de3..2f1c84d8 100644
--- a/vehicle/nissan.go
+++ b/vehicle/nissan.go
@@ -21,6 +21,7 @@ import (
 // Credits to
 //   https://github.com/Tobiaswk/dartnissanconnect
 //   https://github.com/mitchellrj/kamereon-python
+//   https://gitlab.com/tobiaswkjeldsen/carwingsflutter
 
 // OAuth base url
 // 	 https://prod.eu.auth.kamereon.org/kauth/oauth2/a-ncb-prod/.well-known/openid-configuration
@@ -43,6 +44,7 @@ const (
 type Nissan struct {
 	*embed
 	*request.Helper
+	log                 *util.Logger
 	user, password, vin string
 	userID              string
 	tokens              oidc.Tokens
@@ -62,6 +64,7 @@ func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Cache                       time.Duration
 	}{
 		Region: "de_DE",
+		Cache:  interval,
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
@@ -73,6 +76,7 @@ func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	v := &Nissan{
 		embed:    &embed{cc.Title, cc.Capacity},
 		Helper:   request.NewHelper(log),
+		log:      log,
 		user:     cc.User,
 		password: cc.Password,
 		vin:      strings.ToUpper(cc.VIN),
@@ -220,8 +224,10 @@ func (v *Nissan) authFlow() error {
 		uri = fmt.Sprintf("%s/v1/users/current", nissanUserAdapterBaseURL)
 
 		var user struct{ UserID string }
-		if err = v.request(uri, &user); err == nil {
-			v.userID = user.UserID
+		if req, err = request.New(http.MethodGet, uri, nil, nil); err == nil {
+			if err = v.request(req, &user); err == nil {
+				v.userID = user.UserID
+			}
 		}
 
 		if v.userID == "" {
@@ -254,12 +260,10 @@ func (v *Nissan) refreshToken() error {
 	return err
 }
 
-func (v *Nissan) request(uri string, res interface{}) error {
-	req, err := request.New(http.MethodGet, uri, nil, nil)
-	if err == nil {
-		req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken)
-		err = v.DoJSON(req, &res)
-	}
+// request executes given request and handles token refresh
+func (v *Nissan) request(req *http.Request, res interface{}) error {
+	req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken)
+	err := v.DoJSON(req, &res)
 
 	// repeat auth if error
 	if err != nil {
@@ -267,7 +271,8 @@ func (v *Nissan) request(uri string, res interface{}) error {
 			err = v.authFlow()
 		}
 		if err == nil {
-			err = v.request(uri, &res)
+			req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken)
+			err = v.DoJSON(req, &res)
 		}
 	}
 
@@ -288,7 +293,10 @@ func (v *Nissan) vehicles(userID string) ([]string, error) {
 	uri := fmt.Sprintf("%s/v2/users/%s/cars", nissanUserBaseURL, userID)
 
 	var res nissanVehicles
-	err := v.request(uri, &res)
+	req, err := request.New(http.MethodGet, uri, nil, nil)
+	if err == nil {
+		err = v.request(req, &res)
+	}
 
 	var vehicles []string
 	if err == nil {
@@ -302,10 +310,27 @@ func (v *Nissan) vehicles(userID string) ([]string, error) {
 
 // batteryAPI provides battery api response
 func (v *Nissan) batteryAPI() (interface{}, error) {
-	uri := fmt.Sprintf("%s/v1/cars/%s/battery-status", nissanCarAdapterBaseURL, v.vin)
+	// refresh battery status
+	uri := fmt.Sprintf("%s/v1/cars/%s/actions/refresh-battery-status", nissanCarAdapterBaseURL, v.vin)
+
+	data := strings.NewReader(`{"data": {"type": "RefreshBatteryStatus"}}`)
+	req, err := request.New(http.MethodPost, uri, data, map[string]string{
+		"Content-Type": "application/vnd.api+json",
+	})
 
 	var res kamereon.Response
-	err := v.request(uri, &res)
+	if err == nil {
+		err = v.request(req, &res)
+	}
+
+	// request battery status
+	if err == nil {
+		uri = fmt.Sprintf("%s/v1/cars/%s/battery-status", nissanCarAdapterBaseURL, v.vin)
+
+		if req, err = request.New(http.MethodGet, uri, nil, nil); err == nil {
+			err = v.request(req, &res)
+		}
+	}
 
 	return res, err
 }
diff --git a/vehicle/oidc/oidc.go b/vehicle/oidc/oidc.go
index a7787964..66de293b 100644
--- a/vehicle/oidc/oidc.go
+++ b/vehicle/oidc/oidc.go
@@ -4,11 +4,11 @@ import "time"
 
 // Tokens is an OAuth tokens response
 type Tokens struct {
-	TokenType    string `json:"token_type"`
-	ExpiresIn    int    `json:"expires_in"`
-	IDToken      string `json:"id_token"`
-	AccessToken  string `json:"access_token"`
-	RefreshToken string `json:"refresh_token"`
+	TokenType    string    `json:"token_type"`
+	ExpiresIn    int       `json:"expires_in"`
+	IDToken      string    `json:"id_token"`
+	AccessToken  string    `json:"access_token"`
+	RefreshToken string    `json:"refresh_token"`
 	Valid        time.Time // helper to store validity timestamp
 }
 
diff --git a/vehicle/porsche.go b/vehicle/porsche.go
index 65a1f5cf..b297f423 100644
--- a/vehicle/porsche.go
+++ b/vehicle/porsche.go
@@ -68,7 +68,10 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Capacity            int64
 		User, Password, VIN string
 		Cache               time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
+
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
diff --git a/vehicle/renault.go b/vehicle/renault.go
index b76c0f61..059a8b9f 100644
--- a/vehicle/renault.go
+++ b/vehicle/renault.go
@@ -112,6 +112,7 @@ func NewRenaultFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Cache                       time.Duration
 	}{
 		Region: "de_DE",
+		Cache:  interval,
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
diff --git a/vehicle/tesla.go b/vehicle/tesla.go
index 88043d95..f6e1a5d0 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -34,7 +34,10 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		User, Password         string
 		VIN                    string
 		Cache                  time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
+
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index a71cd284..56bf0b4c 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -47,7 +47,9 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error
 		Status   *provider.Config
 		Range    *provider.Config
 		Cache    time.Duration
-	}{}
+	}{
+		Cache: interval,
+	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 25fb4b19..fbedb433 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -379,10 +379,13 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 		MaxCurrent:   maxA,
 		vehicle:      vehicle,      // needed for targetSoC check
 		socEstimator: socEstimator, // instead of vehicle: vehicle,
-		status:       api.StatusC,
 		Mode:         api.ModeNow,
 		SoC: SoCConfig{
 			Target: 90,
+			Poll: PollConfig{
+				Mode:     pollConnected, // allow polling when connected
+				Interval: pollInterval,
+			},
 		},
 	}
 
@@ -391,14 +394,14 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 	lp.enabled = true
 	lp.maxCurrent = float64(minA)
 
-	t.Log("charging below target")
+	t.Log("charging below soc target")
 	vehicle.EXPECT().ChargeState().Return(85.0, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().MaxCurrent(maxA).Return(nil)
 	lp.Update(500)
 
-	t.Log("charging above target deactivates charger")
+	t.Log("charging above target - soc deactivates charger")
 	clock.Add(5 * time.Minute)
 	vehicle.EXPECT().ChargeState().Return(90.0, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
@@ -413,8 +416,14 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	lp.Update(-5000)
 
-	t.Log("soc has fallen below target")
+	t.Log("soc has fallen below target - soc update prevented by timer")
 	clock.Add(5 * time.Minute)
+	charger.EXPECT().Status().Return(api.StatusB, nil)
+	charger.EXPECT().Enabled().Return(lp.enabled, nil)
+	lp.Update(-5000)
+
+	t.Log("soc has fallen below target - soc update timer expired")
+	clock.Add(pollInterval)
 	vehicle.EXPECT().ChargeState().Return(85.0, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)