Repository: andig/evcc
Pull request #1058 (2021-05-20 07:02:37)

Improve loadpoint api
Prepare for advanced use cases like ISO/IEC charging


Patch:
diff --git a/api/api.go b/api/api.go
index 171a3f90..681e8b52 100644
--- a/api/api.go
+++ b/api/api.go
@@ -64,7 +64,7 @@ type ChargeState interface {
 	Status() (ChargeStatus, error)
 }
 
-// Charger is able to provide current charging status and to enable/disabler charging
+// Charger is able to provide current charging status and enable/disable charging
 type Charger interface {
 	ChargeState
 	Enabled() (bool, error)
diff --git a/core/loadpoint.go b/core/loadpoint.go
index acdc9fe7..d8bc862f 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -90,8 +90,8 @@ type LoadPoint struct {
 	}
 	Enable, Disable ThresholdConfig
 
-	MinCurrent    int64         // PV mode: start current	Min+PV mode: min current
-	MaxCurrent    int64         // Max allowed current. Physically ensured by the charger
+	MinCurrent    float64       // PV mode: start current	Min+PV mode: min current
+	MaxCurrent    float64       // Max allowed current. Physically ensured by the charger
 	GuardDuration time.Duration // charger enable/disable minimum holding time
 
 	enabled       bool      // Charger enabled state
@@ -363,7 +363,7 @@ func (lp *LoadPoint) evChargeCurrentHandler(current float64) {
 func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
 	power := current * float64(lp.Phases) * Voltage
 
-	if !lp.enabled || lp.status != api.StatusC {
+	if !lp.enabled || lp.GetStatus() != api.StatusC {
 		// if disabled we cannot be charging
 		power = 0
 	}
@@ -423,7 +423,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 		if lp.enabled = enabled; enabled {
 			lp.guardUpdated = lp.clock.Now()
 			// set defined current for use by pv mode
-			_ = lp.setLimit(float64(lp.MinCurrent), false)
+			_ = lp.setLimit(lp.GetMinCurrent(), false)
 		}
 	} else {
 		lp.log.ERROR.Printf("charger: %v", err)
@@ -435,11 +435,18 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	}
 }
 
+// syncCharger updates charger status and synchronizes it with expectations
 func (lp *LoadPoint) syncCharger() {
 	enabled, err := lp.charger.Enabled()
-	if err == nil && enabled != lp.enabled {
-		lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
-		err = lp.charger.Enable(lp.enabled)
+	if err == nil {
+		if enabled != lp.enabled {
+			lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
+			err = lp.charger.Enable(lp.enabled)
+		}
+
+		if !enabled && lp.GetStatus() == api.StatusC {
+			lp.log.WARN.Println("charger logic error: disabled but charging")
+		}
 	}
 
 	if err != nil {
@@ -447,9 +454,10 @@ func (lp *LoadPoint) syncCharger() {
 	}
 }
 
+// setLimit applies charger current limits and enables/disables accordingly
 func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
 	// set current
-	if chargeCurrent != lp.chargeCurrent && chargeCurrent >= float64(lp.MinCurrent) {
+	if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.GetMinCurrent() {
 		if charger, ok := lp.charger.(api.ChargerEx); ok {
 			lp.log.DEBUG.Printf("max charge current: %.2g", chargeCurrent)
 			err = charger.MaxCurrentMillis(chargeCurrent)
@@ -467,7 +475,7 @@ func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
 	}
 
 	// set enabled
-	if enabled := chargeCurrent >= float64(lp.MinCurrent); enabled != lp.enabled && err == nil {
+	if enabled := chargeCurrent >= lp.GetMinCurrent(); enabled != lp.enabled && err == nil {
 		if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force {
 			lp.log.DEBUG.Printf("charger %s: contactor delay %v", status[enabled], remaining)
 			return nil
@@ -503,12 +511,20 @@ func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) {
 
 // connected returns the EVs connection state
 func (lp *LoadPoint) connected() bool {
-	return lp.status == api.StatusB || lp.status == api.StatusC
+	status := lp.GetStatus()
+	return status == api.StatusB || status == api.StatusC
 }
 
 // charging returns the EVs charging state
 func (lp *LoadPoint) charging() bool {
-	return lp.status == api.StatusC
+	return lp.GetStatus() == api.StatusC
+}
+
+// charging returns the EVs charging state
+func (lp *LoadPoint) setStatus(status api.ChargeStatus) {
+	lp.Lock()
+	defer lp.Unlock()
+	lp.status = status
 }
 
 // targetSocReached checks if target is configured and reached.
@@ -662,8 +678,8 @@ func (lp *LoadPoint) updateChargerStatus() error {
 
 	lp.log.DEBUG.Printf("charger status: %s", status)
 
-	if prevStatus := lp.status; status != prevStatus {
-		lp.status = status
+	if prevStatus := lp.GetStatus(); status != prevStatus {
+		lp.setStatus(status)
 
 		// changed from empty (initial startup) - set connected without sending message
 		if prevStatus == api.StatusNone {
@@ -703,9 +719,10 @@ func (lp *LoadPoint) effectiveCurrent() float64 {
 		return lp.chargeCurrents[0]
 	}
 
-	if lp.status != api.StatusC {
+	if lp.GetStatus() != api.StatusC {
 		return 0
 	}
+
 	return lp.chargeCurrent
 }
 
@@ -719,17 +736,18 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
 	// calculate target charge current from delta power and actual current
 	effectiveCurrent := lp.effectiveCurrent()
 	deltaCurrent := powerToCurrent(-sitePower, lp.Phases)
-	targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, float64(lp.MaxCurrent)), 0)
+	targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, lp.GetMaxCurrent()), 0)
 
 	lp.log.DEBUG.Printf("max charge current: %.1fA = %.1fA + %.1fA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, lp.Phases)
 
 	// in MinPV mode return at least minCurrent
-	if mode == api.ModeMinPV && targetCurrent < float64(lp.MinCurrent) {
-		return float64(lp.MinCurrent)
+	minCurrent := lp.GetMinCurrent()
+	if mode == api.ModeMinPV && targetCurrent < minCurrent {
+		return minCurrent
 	}
 
 	// read only once to simplify testing
-	if mode == api.ModePV && lp.enabled && targetCurrent < float64(lp.MinCurrent) {
+	if mode == api.ModePV && lp.enabled && targetCurrent < minCurrent {
 		// kick off disable sequence
 		if sitePower >= lp.Disable.Threshold {
 			lp.log.DEBUG.Printf("site power %.0fW >= disable threshold %.0fW", sitePower, lp.Disable.Threshold)
@@ -748,15 +766,17 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
 			lp.log.DEBUG.Printf("pv disable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second))
 		} else {
 			// reset timer
+			lp.log.DEBUG.Printf("reset pv disable timer: %v", lp.Disable.Delay)
 			lp.pvTimer = lp.clock.Now()
 		}
 
-		return float64(lp.MinCurrent)
+		lp.log.DEBUG.Println("pv enable timer: keep enabled")
+		return minCurrent
 	}
 
 	if mode == api.ModePV && !lp.enabled {
 		// kick off enable sequence
-		if (lp.Enable.Threshold == 0 && targetCurrent >= float64(lp.MinCurrent)) ||
+		if (lp.Enable.Threshold == 0 && targetCurrent >= minCurrent) ||
 			(lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
 			lp.log.DEBUG.Printf("site power %.0fW < enable threshold %.0fW", sitePower, lp.Enable.Threshold)
 
@@ -768,15 +788,17 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
 			elapsed := lp.clock.Since(lp.pvTimer)
 			if elapsed >= lp.Enable.Delay {
 				lp.log.DEBUG.Println("pv enable timer elapsed")
-				return float64(lp.MinCurrent)
+				return minCurrent
 			}
 
 			lp.log.DEBUG.Printf("pv enable timer remaining: %v", (lp.Enable.Delay - elapsed).Round(time.Second))
 		} else {
 			// reset timer
+			lp.log.DEBUG.Printf("reset pv enable timer: %v", lp.Enable.Delay)
 			lp.pvTimer = lp.clock.Now()
 		}
 
+		lp.log.DEBUG.Println("pv enable timer: keep disabled")
 		return 0
 	}
 
@@ -980,7 +1002,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
 		var targetCurrent float64 // zero disables
 		if lp.climateActive() {
 			lp.log.DEBUG.Println("climater active")
-			targetCurrent = float64(lp.MinCurrent)
+			targetCurrent = lp.GetMinCurrent()
 		}
 		err = lp.setLimit(targetCurrent, true)
 		lp.socTimer.Reset() // once SoC is reached, the target charge request is removed
@@ -994,11 +1016,11 @@ func (lp *LoadPoint) Update(sitePower float64) {
 		err = lp.setLimit(0, true)
 
 	case lp.minSocNotReached():
-		err = lp.setLimit(float64(lp.MaxCurrent), true)
+		err = lp.setLimit(lp.GetMaxCurrent(), true)
 		lp.pvDisableTimer() // let PV mode disable immediately afterwards
 
 	case mode == api.ModeNow:
-		err = lp.setLimit(float64(lp.MaxCurrent), true)
+		err = lp.setLimit(lp.GetMaxCurrent(), true)
 
 	// target charging
 	case lp.socTimer.StartRequired():
@@ -1011,7 +1033,7 @@ func (lp *LoadPoint) Update(sitePower float64) {
 
 		var required bool // false
 		if targetCurrent == 0 && lp.climateActive() {
-			targetCurrent = float64(lp.MinCurrent)
+			targetCurrent = lp.GetMaxCurrent()
 			required = true
 		}
 
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 91a9e9b1..00e423e8 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -17,6 +17,9 @@ type LoadPointAPI interface {
 	Name() string
 	HasChargeMeter() bool
 
+	// status
+	GetStatus() api.ChargeStatus
+
 	// settings
 	GetMode() api.ChargeMode
 	SetMode(api.ChargeMode)
@@ -28,10 +31,19 @@ type LoadPointAPI interface {
 	RemoteControl(string, RemoteDemand)
 
 	// energy
-	GetMinCurrent() int64
-	GetMaxCurrent() int64
-	GetMinPower() int64
-	GetMaxPower() int64
+	GetMinCurrent() float64
+	SetMinCurrent(float64)
+	GetMaxCurrent() float64
+	SetMaxCurrent(float64)
+	GetMinPower() float64
+	GetMaxPower() float64
+}
+
+// GetStatus returns the charging status
+func (lp *LoadPoint) GetStatus() api.ChargeStatus {
+	lp.Lock()
+	defer lp.Unlock()
+	return lp.status
 }
 
 // GetMode returns loadpoint charge mode
@@ -158,22 +170,48 @@ func (lp *LoadPoint) HasChargeMeter() bool {
 	return lp.chargeMeter != nil && !isWrapped
 }
 
-// GetMinCurrent returns the minimal loadpoint current
-func (lp *LoadPoint) GetMinCurrent() int64 {
+// GetMinCurrent returns the min loadpoint current
+func (lp *LoadPoint) GetMinCurrent() float64 {
+	lp.Lock()
+	defer lp.Unlock()
 	return lp.MinCurrent
 }
 
-// GetMaxCurrent returns the minimal loadpoint current
-func (lp *LoadPoint) GetMaxCurrent() int64 {
+// SetMinCurrent returns the min loadpoint current
+func (lp *LoadPoint) SetMinCurrent(current float64) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	if current != lp.MinCurrent {
+		lp.MinCurrent = current
+		lp.publish("minCurrent", lp.MinCurrent)
+	}
+}
+
+// GetMaxCurrent returns the max loadpoint current
+func (lp *LoadPoint) GetMaxCurrent() float64 {
+	lp.Lock()
+	defer lp.Unlock()
 	return lp.MaxCurrent
 }
 
-// GetMinPower returns the minimal loadpoint power for a single phase
-func (lp *LoadPoint) GetMinPower() int64 {
-	return int64(Voltage) * lp.MinCurrent
+// SetMaxCurrent returns the max loadpoint current
+func (lp *LoadPoint) SetMaxCurrent(current float64) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	if current != lp.MaxCurrent {
+		lp.MaxCurrent = current
+		lp.publish("maxCurrent", lp.MaxCurrent)
+	}
+}
+
+// GetMinPower returns the min loadpoint power for a single phase
+func (lp *LoadPoint) GetMinPower() float64 {
+	return Voltage * lp.GetMinCurrent()
 }
 
-// GetMaxPower returns the minimal loadpoint power taking active phases into account
-func (lp *LoadPoint) GetMaxPower() int64 {
-	return int64(Voltage) * lp.Phases * lp.MaxCurrent
+// GetMaxPower returns the max loadpoint power taking active phases into account
+func (lp *LoadPoint) GetMaxPower() float64 {
+	return Voltage * lp.GetMaxCurrent() * float64(lp.Phases)
 }
diff --git a/core/soc/timer.go b/core/soc/timer.go
index da6b4d1b..b1bd2823 100644
--- a/core/soc/timer.go
+++ b/core/soc/timer.go
@@ -23,7 +23,7 @@ type Adapter interface {
 type Timer struct {
 	Adapter
 	log            *util.Logger
-	maxCurrent     int64
+	maxCurrent     float64
 	current        float64
 	SoC            int
 	Time           time.Time
@@ -32,7 +32,7 @@ type Timer struct {
 }
 
 // NewTimer creates a Timer
-func NewTimer(log *util.Logger, adapter Adapter, maxCurrent int64) *Timer {
+func NewTimer(log *util.Logger, adapter Adapter, maxCurrent float64) *Timer {
 	lp := &Timer{
 		log:        log,
 		Adapter:    adapter,
@@ -64,7 +64,7 @@ func (lp *Timer) StartRequired() bool {
 		return false
 	}
 
-	power := float64(lp.maxCurrent*lp.ActivePhases()) * lp.Voltage()
+	power := float64(lp.ActivePhases()) * lp.maxCurrent * lp.Voltage()
 
 	// time
 	remainingDuration := se.RemainingChargeDuration(power, lp.SoC)
diff --git a/hems/ocpp/ocpp.go b/hems/ocpp/ocpp.go
index c60ab516..f7fbd5d6 100644
--- a/hems/ocpp/ocpp.go
+++ b/hems/ocpp/ocpp.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/andig/evcc/api"
 	"github.com/andig/evcc/core"
 	"github.com/andig/evcc/hems/ocpp/profile"
 	"github.com/andig/evcc/util"
@@ -81,14 +82,12 @@ func (s *OCPP) errorHandler(errC <-chan error) {
 // Run executes the OCPP chargepoint client
 func (s *OCPP) Run() {
 	for {
-		for id := range s.site.LoadPoints() {
+		for id, lp := range s.site.LoadPoints() {
 			connector := id + 1
 
 			status := ocppcore.ChargePointStatusAvailable
-			if statusP, err := s.cache.GetChecked(id, "charging"); err == nil {
-				if statusP.Val.(bool) {
-					status = ocppcore.ChargePointStatusCharging
-				}
+			if lp.GetStatus() == api.StatusC {
+				status = ocppcore.ChargePointStatusCharging
 			}
 
 			s.log.TRACE.Printf("send: lp-%d status: %+v", connector, status)
diff --git a/hems/semp/semp.go b/hems/semp/semp.go
index 5a9e8957..cb439994 100644
--- a/hems/semp/semp.go
+++ b/hems/semp/semp.go
@@ -428,21 +428,12 @@ func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
 	return res
 }
 
-func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningRequest) {
-	mode := api.ModeOff
-	if modeP, err := s.cache.GetChecked(id, "mode"); err == nil {
-		mode = modeP.Val.(api.ChargeMode)
-	}
+// TODO remove GetChecked function
 
-	var connected bool
-	if connectedP, err := s.cache.GetChecked(id, "connected"); err == nil {
-		connected = connectedP.Val.(bool)
-	}
-
-	var charging bool
-	if chargingP, err := s.cache.GetChecked(id, "charging"); err == nil {
-		charging = chargingP.Val.(bool)
-	}
+func (s *SEMP) planningRequest(id int, lp core.LoadPointAPI) (res PlanningRequest) {
+	mode := lp.GetMode()
+	charging := lp.GetStatus() == api.StatusC
+	connected := charging || lp.GetStatus() == api.StatusB
 
 	chargeEstimate := time.Duration(-1)
 	if chargeEstimateP, err := s.cache.GetChecked(id, "chargeEstimate"); err == nil {


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 1e27aa6f..fa5b9d89 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -15,8 +15,8 @@ import (
 )
 
 const (
-	minA int64 = 6
-	maxA int64 = 16
+	minA float64 = 6
+	maxA float64 = 16
 )
 
 type Null struct{}
@@ -62,7 +62,7 @@ func attachListeners(t *testing.T, lp *LoadPoint) {
 
 	if charger, ok := lp.charger.(*mock.MockCharger); ok && charger != nil {
 		charger.EXPECT().Enabled().Return(true, nil)
-		charger.EXPECT().MaxCurrent(lp.MinCurrent).Return(nil)
+		charger.EXPECT().MaxCurrent(int64(lp.MinCurrent)).Return(nil)
 	}
 
 	lp.Prepare(uiChan, pushChan, lpChan)
@@ -111,7 +111,7 @@ func TestUpdatePowerZero(t *testing.T) {
 			h.EXPECT().Enable(false)
 		}},
 		{api.StatusB, api.ModeNow, func(h *mock.MockCharger) {
-			h.EXPECT().MaxCurrent(maxA) // true
+			h.EXPECT().MaxCurrent(int64(maxA)) // true
 		}},
 		{api.StatusB, api.ModeMinPV, func(h *mock.MockCharger) {
 			// MaxCurrent omitted since identical value
@@ -126,7 +126,7 @@ func TestUpdatePowerZero(t *testing.T) {
 			h.EXPECT().Enable(false)
 		}},
 		{api.StatusC, api.ModeNow, func(h *mock.MockCharger) {
-			h.EXPECT().MaxCurrent(maxA) // true
+			h.EXPECT().MaxCurrent(int64(maxA)) // true
 		}},
 		{api.StatusC, api.ModeMinPV, func(h *mock.MockCharger) {
 			// MaxCurrent omitted since identical value
@@ -179,7 +179,7 @@ func TestPVHysteresis(t *testing.T) {
 	type se struct {
 		site    float64
 		delay   time.Duration // test case delay since start
-		current int64
+		current float64
 	}
 	tc := []struct {
 		enabled         bool
@@ -325,8 +325,8 @@ func TestPVHysteresis(t *testing.T) {
 				lp.enabled = tc.enabled
 				current := lp.pvMaxCurrent(api.ModePV, se.site)
 
-				if current != float64(se.current) {
-					t.Errorf("step %d: wanted %d, got %.f", step, se.current, current)
+				if current != se.current {
+					t.Errorf("step %d: wanted %.1f, got %.1f", step, se.current, current)
 				}
 			}
 
@@ -351,10 +351,8 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
 	// not connected, test PV mode logic  short-circuited
 	lp.status = api.StatusA
 
-	// maxCurrent will read actual current in PV mode
-
 	// maxCurrent will read enabled state in PV mode
-	sitePower := -float64(minA*lp.Phases)*Voltage + 1 // 1W below min power
+	sitePower := -float64(lp.Phases)*minA*Voltage + 1 // 1W below min power
 	current := lp.pvMaxCurrent(api.ModePV, sitePower)
 
 	if current != 0 {
@@ -405,7 +403,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 	vehicle.EXPECT().SoC().Return(85.0, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
-	charger.EXPECT().MaxCurrent(maxA).Return(nil)
+	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
 	lp.Update(500)
 
 	t.Log("charging above target - soc deactivates charger")
@@ -474,7 +472,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 	t.Log("charging at min")
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	charger.EXPECT().MaxCurrent(maxA).Return(nil)
+	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
 	lp.Update(500)
 
 	t.Log("switch off when disconnected")