From 9b16f9bcf2b9f259ffc5dcaee0a123c0ea972c8c Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 18 Nov 2021 11:37:14 +0100 Subject: [PATCH] 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 --- api/api.go | 12 +++++- cmd/dumper.go | 1 + core/loadpoint.go | 82 +++++++++++++++++++++++++++----------- core/loadpoint_api.go | 17 ++++---- core/loadpoint_test.go | 49 +++++++++++------------ evcc.dist.yaml | 14 +++---- mock/mock_api.go | 31 +++++++++----- vehicle/embed.go | 30 ++++++++++++++ vehicle/vehicle.go | 21 ---------- vehicle/wrapper/wrapper.go | 13 ++++-- 10 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 vehicle/embed.go diff --git a/api/api.go b/api/api.go index b03d29643..fe7f88181 100644 --- a/api/api.go +++ b/api/api.go @@ -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 diff --git a/cmd/dumper.go b/cmd/dumper.go index 9bfc74995..72340180b 100644 --- a/cmd/dumper.go +++ b/cmd/dumper.go @@ -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() diff --git a/core/loadpoint.go b/core/loadpoint.go index 2679c99ff..db3114cad 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 { diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 960773f0e..de46bc2d1 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index b9defdc40..bc088a89b 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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}) }}, } diff --git a/evcc.dist.yaml b/evcc.dist.yaml index bfac9d3f0..8166e64aa 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -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 diff --git a/mock/mock_api.go b/mock/mock_api.go index 833ca77e3..60e518c16 100644 --- a/mock/mock_api.go +++ b/mock/mock_api.go @@ -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. diff --git a/vehicle/embed.go b/vehicle/embed.go new file mode 100644 index 000000000..bfc878eb6 --- /dev/null +++ b/vehicle/embed.go @@ -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 +} diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go index 84a1c48c0..c8d559bbe 100644 --- a/vehicle/vehicle.go +++ b/vehicle/vehicle.go @@ -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. diff --git a/vehicle/wrapper/wrapper.go b/vehicle/wrapper/wrapper.go index e41187b78..3ea04e083 100644 --- a/vehicle/wrapper/wrapper.go +++ b/vehicle/wrapper/wrapper.go @@ -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