Improve vehicle identification experience (#1889)

- allow multiple identifiers per vehicle to support RFID and MAC across multiple chargers
- move onIdentify action config from loadpoint to vehicle
- add minSoC to action config
- replace onDisconnect with simpler resetOnDisconnect
This commit is contained in:
andig 2021-11-18 11:37:14 +01:00 • committed by GitHub
parent 15a51a13a4
commit 9b16f9bcf2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 172 additions and 98 deletions

View file

@ -40,6 +40,15 @@ func (c ChargeStatus) String() string {
return string(c)
}
// ActionConfig defines an action to take on event
type ActionConfig struct {
Mode ChargeMode `mapstructure:"mode"` // Charge mode
MinCurrent float64 `mapstructure:"minCurrent"` // Minimum Current
MaxCurrent float64 `mapstructure:"maxCurrent"` // Maximum Current
MinSoC int `mapstructure:"minSoC"` // Minimum SoC
TargetSoC int `mapstructure:"targetSoC"` // Target SoC
}
// Meter is able to provide current power in W
type Meter interface {
CurrentPower() (float64, error)
@ -111,9 +120,10 @@ type Authorizer interface {
// Vehicle represents the EV and it's battery
type Vehicle interface {
Battery
Identifier
Title() string
Capacity() int64
Identifiers() []string
OnIdentified() ActionConfig
}
// VehicleFinishTimer provides estimated charge cycle finish time

View file

@ -179,6 +179,7 @@ func (d *dumper) Dump(name string, v interface{}) {
if v, ok := v.(api.Vehicle); ok {
fmt.Fprintf(w, "Capacity:\t%dkWh\n", v.Capacity())
fmt.Fprintf(w, "Identifiers:\t%v\n", v.Identifiers())
}
w.Flush()

View file

@ -16,6 +16,7 @@ import (
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
"github.com/thoas/go-funk"
evbus "github.com/asaskevich/EventBus"
"github.com/avast/retry-go/v3"
@ -65,12 +66,6 @@ type ThresholdConfig struct {
Threshold float64
}
// ActionConfig defines an action to take on event
type ActionConfig struct {
Mode api.ChargeMode `mapstructure:"mode"` // Charge mode to apply when car disconnected
TargetSoC int `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected
}
// LoadPoint is responsible for controlling charge depending on
// SoC needs and power availability.
type LoadPoint struct {
@ -93,10 +88,12 @@ type LoadPoint struct {
Meters struct {
ChargeMeterRef string `mapstructure:"charge"` // Charge meter reference
}
SoC SoCConfig
OnDisconnect ActionConfig `mapstructure:"onDisconnect"`
OnIdentify map[string]ActionConfig `mapstructure:"onIdentify"`
Enable, Disable ThresholdConfig
SoC SoCConfig
OnDisconnect_ interface{} `mapstructure:"onDisconnect"`
OnIdentify_ interface{} `mapstructure:"onIdentify"`
Enable, Disable ThresholdConfig
ResetOnDisconnect bool `mapstructure:"resetOnDisconnect"`
onDisconnect api.ActionConfig
MinCurrent float64 // PV mode: start current Min+PV mode: min current
MaxCurrent float64 // Max allowed current. Physically ensured by the charger
@ -157,6 +154,14 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.SoC.Poll.Mode = pollConnected
}
if lp.OnIdentify_ != nil {
lp.log.WARN.Printf("loadpoint.onIdentify is deprecated and will be removed in a future release. Use vehicle.onIdentify instead.")
}
if lp.OnDisconnect_ != nil {
lp.log.WARN.Printf("loadpoint.onDisconnect is deprecated and will be removed in a future release. Use loadpoint.resetOnDisconnect instead.")
}
// set vehicle polling interval
if lp.SoC.Poll.Interval < pollInterval {
if lp.SoC.Poll.Interval == 0 {
@ -167,7 +172,7 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
}
if lp.SoC.Target == 0 {
lp.SoC.Target = lp.OnDisconnect.TargetSoC // use disconnect value as default soc
lp.SoC.Target = lp.onDisconnect.TargetSoC // use disconnect value as default soc
if lp.SoC.Target == 0 {
lp.SoC.Target = 100
}
@ -181,6 +186,9 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.log.WARN.Println("maxCurrent must be larger than minCurrent")
}
// store defaults
lp.collectDefaults()
if lp.Meters.ChargeMeterRef != "" {
lp.chargeMeter = cp.Meter(lp.Meters.ChargeMeterRef)
}
@ -242,6 +250,17 @@ func NewLoadPoint(log *util.Logger) *LoadPoint {
return lp
}
// collectDefaults collects default values for use on disconnect
func (lp *LoadPoint) collectDefaults() {
lp.onDisconnect = api.ActionConfig{
Mode: lp.GetMode(),
MinCurrent: lp.GetMinCurrent(),
MaxCurrent: lp.GetMaxCurrent(),
MinSoC: lp.GetMinSoC(),
TargetSoC: lp.GetTargetSoC(),
}
}
// requestUpdate requests site to update this loadpoint
func (lp *LoadPoint) requestUpdate() {
select {
@ -367,7 +386,9 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
}
// set default mode on disconnect
lp.applyAction(lp.OnDisconnect)
if lp.ResetOnDisconnect {
lp.applyAction(lp.onDisconnect)
}
// soc update reset
lp.socUpdated = time.Time{}
@ -399,12 +420,29 @@ func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
}
// applyAction executes the action
func (lp *LoadPoint) applyAction(action ActionConfig) {
if action.Mode != "" && lp.GetMode() != api.ModeEmpty {
func (lp *LoadPoint) applyAction(action api.ActionConfig) {
if action.Mode != api.ModeEmpty {
lp.SetMode(action.Mode)
}
if action.MinCurrent > 0 {
lp.SetMinCurrent(action.MinCurrent)
}
if action.MaxCurrent > 0 {
lp.SetMaxCurrent(action.MaxCurrent)
}
if action.MinSoC != 0 {
// TODO deduplicate with SetMinSoC
lp.Lock()
lp.SoC.Min = action.MinSoC
lp.publish("minSoC", action.MinSoC)
lp.Unlock()
}
if action.TargetSoC != 0 {
_ = lp.SetTargetSoC(action.TargetSoC)
// TODO deduplicate with SetTargetSoC
lp.Lock()
lp.SoC.Target = action.TargetSoC
lp.publish("targetSoC", action.TargetSoC)
lp.Unlock()
}
}
@ -647,11 +685,6 @@ func (lp *LoadPoint) identifyVehicle() {
if vehicle := lp.selectVehicleByID(id); vehicle != nil {
lp.setActiveVehicle(vehicle)
}
if action, ok := lp.OnIdentify[id]; ok {
lp.log.DEBUG.Println("running vehicle action:", action)
lp.applyAction(action)
}
}
}
@ -659,14 +692,14 @@ func (lp *LoadPoint) identifyVehicle() {
func (lp *LoadPoint) selectVehicleByID(id string) api.Vehicle {
// find exact match
for _, vehicle := range lp.vehicles {
if vid, err := vehicle.Identify(); err == nil && vid == id {
if funk.ContainsString(vehicle.Identifiers(), id) {
return vehicle
}
}
// find placeholder match
for _, vehicle := range lp.vehicles {
if vid, err := vehicle.Identify(); err == nil && vid != "" {
for _, vid := range vehicle.Identifiers() {
re, err := regexp.Compile(strings.ReplaceAll(vid, "*", ".*?"))
if err != nil {
lp.log.ERROR.Printf("vehicle id: %v", err)
@ -706,6 +739,8 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
lp.publish("vehiclePresent", true)
lp.publish("vehicleTitle", lp.vehicle.Title())
lp.publish("vehicleCapacity", lp.vehicle.Capacity())
lp.applyAction(vehicle.OnIdentified())
} else {
lp.socEstimator = nil
@ -726,7 +761,8 @@ func (lp *LoadPoint) startVehicleDetection() {
// vehicleUnidentified checks if loadpoint has multiple vehicles associated and starts discovery period
func (lp *LoadPoint) vehicleUnidentified() bool {
res := len(lp.vehicles) > 1 && lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration
res := len(lp.vehicles) > 1 && lp.vehicle == nil &&
lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration
// request vehicle api refresh while waiting to identify
if res {

View file

@ -57,13 +57,13 @@ func (lp *LoadPoint) GetTargetSoC() int {
// SetTargetSoC sets loadpoint charge target soc
func (lp *LoadPoint) SetTargetSoC(soc int) error {
lp.Lock()
defer lp.Unlock()
if lp.vehicle == nil {
return api.ErrNotAvailable
}
lp.Lock()
defer lp.Unlock()
lp.log.INFO.Println("set target soc:", soc)
// apply immediately
@ -85,13 +85,13 @@ func (lp *LoadPoint) GetMinSoC() int {
// SetMinSoC sets loadpoint charge minimum soc
func (lp *LoadPoint) SetMinSoC(soc int) error {
lp.Lock()
defer lp.Unlock()
if lp.vehicle == nil {
return api.ErrNotAvailable
}
lp.Lock()
defer lp.Unlock()
lp.log.INFO.Println("set min soc:", soc)
// apply immediately
@ -108,7 +108,6 @@ func (lp *LoadPoint) SetMinSoC(soc int) error {
func (lp *LoadPoint) GetPhases() int {
lp.Lock()
defer lp.Unlock()
return lp.Phases
}
@ -178,6 +177,8 @@ func (lp *LoadPoint) SetMinCurrent(current float64) {
lp.Lock()
defer lp.Unlock()
lp.log.INFO.Println("set min current:", current)
if current != lp.MinCurrent {
lp.MinCurrent = current
lp.publish("minCurrent", lp.MinCurrent)
@ -196,6 +197,8 @@ func (lp *LoadPoint) SetMaxCurrent(current float64) {
lp.Lock()
defer lp.Unlock()
lp.log.INFO.Println("set max current:", current)
if current != lp.MaxCurrent {
lp.MaxCurrent = current
lp.publish("maxCurrent", lp.MaxCurrent)

View file

@ -456,16 +456,15 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
OnDisconnect: struct {
Mode api.ChargeMode `mapstructure:"mode"` // Charge mode to apply when car disconnected
TargetSoC int `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected
}{
Mode: api.ModeOff,
TargetSoC: 70,
Mode: api.ModeOff,
SoC: SoCConfig{
Target: 70,
},
ResetOnDisconnect: true,
}
attachListeners(t, lp)
lp.collectDefaults()
lp.enabled = true
lp.chargeCurrent = float64(minA)
@ -764,35 +763,35 @@ func TestVehicleDetectByID(t *testing.T) {
}
tc := []testcase{
{"1/_/_->0", "1", "", "", nil, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identifiers().Return(nil)
v2.EXPECT().Identifiers().Return(nil)
v1.EXPECT().Identifiers().Return(nil)
v2.EXPECT().Identifiers().Return(nil)
}},
{"1/1/2->1", "1", "1", "2", v1, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v1.EXPECT().Identifiers().Return([]string{tc.i1})
}},
{"2/1/2->2", "2", "1", "2", v2, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identifiers().Return([]string{tc.i1})
v2.EXPECT().Identifiers().Return([]string{tc.i2})
}},
{"11/1*/2->1", "11", "1*", "2", v1, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identify().Return(tc.i1, nil)
// v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identifiers().Return([]string{tc.i1})
v2.EXPECT().Identifiers().Return([]string{tc.i2})
v1.EXPECT().Identifiers().Return([]string{tc.i1})
// v2.EXPECT().Identifiers().Return([]string{tc.i2})
}},
{"22/1*/2*->2", "22", "1*", "2*", v2, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identifiers().Return([]string{tc.i1})
v2.EXPECT().Identifiers().Return([]string{tc.i2})
v1.EXPECT().Identifiers().Return([]string{tc.i1})
v2.EXPECT().Identifiers().Return([]string{tc.i2})
}},
{"2/_/*->2", "2", "", "*", v2, func(tc testcase) {
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identify().Return(tc.i1, nil)
v2.EXPECT().Identify().Return(tc.i2, nil)
v1.EXPECT().Identifiers().Return(nil)
v2.EXPECT().Identifiers().Return([]string{tc.i2})
v1.EXPECT().Identifiers().Return(nil)
v2.EXPECT().Identifiers().Return([]string{tc.i2})
}},
}

View file

@ -44,14 +44,16 @@ chargers:
# name can be freely chosen and is used as reference when assigning vehicle to loadpoint
# for examples see https://github.com/evcc-io/config#vehicles
vehicles:
- name: renault
- name: car1
type: renault
title: Zoe
capacity: 60 # kWh
user: # user
password: # password
vin: WREN...
cache: 5m
onIdentify: # set defaults when vehicle is identified
minSoC: 20 # charge to at least 20% independent of charge mode
targetSoC: 90 # limit charge to 90%
# site describes the EVU connection, PV and home battery
site:
@ -75,6 +77,7 @@ loadpoints:
# - ID.3
# - e-Up
mode: pv
resetOnDisconnect: true # set defaults when vehicle disconnects
soc:
# polling defines usage of the vehicle APIs
# Modifying the default settings it NOT recommended. It MAY deplete your vehicle's battery
@ -90,13 +93,6 @@ loadpoints:
min: 0 # immediately charge to 0% regardless of mode unless "off" (disabled)
target: 100 # always charge to 100%
estimate: false # set true to interpolate between api updates
onDisconnect: # set defaults when vehicle disconnects
mode: pv # switch back to pv mode
targetSoC: 100 # charge to 100%
onIdentify: # set defaults when vehicle is identified
- e-Up:
mode: pv # switch back to pv mode
targetSoC: 100 # charge to 100%
phases: 3 # ev phases (default 3)
enable: # pv mode enable behavior
delay: 1m # threshold must be exceeded for this long

View file

@ -318,19 +318,32 @@ func (mr *MockVehicleMockRecorder) Capacity() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Capacity", reflect.TypeOf((*MockVehicle)(nil).Capacity))
}
// Identify mocks base method.
func (m *MockVehicle) Identify() (string, error) {
// Identifiers mocks base method.
func (m *MockVehicle) Identifiers() []string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Identify")
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
ret := m.ctrl.Call(m, "Identifiers")
ret0, _ := ret[0].([]string)
return ret0
}
// Identify indicates an expected call of Identify.
func (mr *MockVehicleMockRecorder) Identify() *gomock.Call {
// Identifiers indicates an expected call of Identifiers.
func (mr *MockVehicleMockRecorder) Identifiers() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Identify", reflect.TypeOf((*MockVehicle)(nil).Identify))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Identifiers", reflect.TypeOf((*MockVehicle)(nil).Identifiers))
}
// OnIdentified mocks base method.
func (m *MockVehicle) OnIdentified() api.ActionConfig {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "OnIdentified")
ret0, _ := ret[0].(api.ActionConfig)
return ret0
}
// OnIdentified indicates an expected call of OnIdentified.
func (mr *MockVehicleMockRecorder) OnIdentified() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnIdentified", reflect.TypeOf((*MockVehicle)(nil).OnIdentified))
}
// SoC mocks base method.

30
vehicle/embed.go Normal file
View file

@ -0,0 +1,30 @@
package vehicle
import "github.com/evcc-io/evcc/api"
type embed struct {
Title_ string `mapstructure:"title"`
Capacity_ int64 `mapstructure:"capacity"`
Identifiers_ []string `mapstructure:"identifiers"`
OnIdentify api.ActionConfig `mapstructure:"onIdentify"`
}
// Title implements the api.Vehicle interface
func (v *embed) Title() string {
return v.Title_
}
// Capacity implements the api.Vehicle interface
func (v *embed) Capacity() int64 {
return v.Capacity_
}
// Identifiers implements the api.Identifier interface
func (v *embed) Identifiers() []string {
return v.Identifiers_
}
// OnIdentified returns the identify action
func (v *embed) OnIdentified() api.ActionConfig {
return v.OnIdentify
}

View file

@ -9,27 +9,6 @@ import (
"github.com/evcc-io/evcc/util"
)
type embed struct {
Title_ string `mapstructure:"title"`
Capacity_ int64 `mapstructure:"capacity"`
Identifier_ string `mapstructure:"identifier"`
}
// Title implements the api.Vehicle interface
func (v *embed) Title() string {
return v.Title_
}
// Capacity implements the api.Vehicle interface
func (v *embed) Capacity() int64 {
return v.Capacity_
}
// Identify implements the api.Identifier interface
func (v *embed) Identify() (string, error) {
return v.Identifier_, nil
}
//go:generate go run ../cmd/tools/decorate.go -f decorateVehicle -b api.Vehicle -t "api.ChargeState,Status,func() (api.ChargeStatus, error)" -t "api.VehicleRange,Range,func() (int64, error)" -t "api.VehicleOdometer,Odometer,func() (float64, error)"
// Vehicle is an api.Vehicle implementation with configurable getters and setters.

View file

@ -30,11 +30,18 @@ func (v *Wrapper) Capacity() int64 {
return 0
}
// Identify implements the api.Identifier interface
func (v *Wrapper) Identify() (string, error) {
return "", v.err
// Identifiers implements the api.Vehicle interface
func (v *Wrapper) Identifiers() []string {
return nil
}
// OnIdentified returns the identify action
func (v *Wrapper) OnIdentified() api.ActionConfig {
return api.ActionConfig{}
}
var _ api.Battery = (*Wrapper)(nil)
// SoC implements the api.Battery interface
func (v *Wrapper) SoC() (float64, error) {
return 0, v.err