Skip to content

Commit

Permalink
Make vehicle soc polling more secure (evcc-io#508)
Browse files Browse the repository at this point in the history
- Defaultwert für Cache von 5m auf 15m. Häufiger kann in keinem Fall abgefragt werden. Warnung falls kürzere Zeit eingestellt
- Einschränkung der Updates auf den Ladevorgang über einen poll Modus, per default nur wenn charging
- Abfrageintervall für Zustände außerhalb "charging" konfigurierbar als poll: interval, Default 1h.
- Einmalige Warnung falls häufiger.
- Falls poll: mode: connected Abfrageintervall per default nur 1x je Stunde
- Falls poll: mode: always Abfrageintervall per default 1x je Stunde
- Reset poll timeout on (dis)connect and charger enable/disable events
  • Loading branch information
andig authored Dec 8, 2020
1 parent d564de7 commit a3e6e64
Show file tree
Hide file tree
Showing 16 changed files with 161 additions and 44 deletions.
125 changes: 99 additions & 26 deletions core/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"math"
"sort"
"strings"
"sync"
"time"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
17 changes: 13 additions & 4 deletions core/loadpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
}

Expand All @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion evcc.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion vehicle/audi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion vehicle/bmw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions vehicle/carwings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions vehicle/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 3 additions & 1 deletion vehicle/ford.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion vehicle/hyundai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion vehicle/kia.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions vehicle/nissan.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,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 {
Expand Down
10 changes: 5 additions & 5 deletions vehicle/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 4 additions & 1 deletion vehicle/porsche.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit a3e6e64

Please sign in to comment.