Repository: andig/evcc
Pull request #104 (2020-05-06 13:38:29)

Split loadpoint and charger handler
Provides basis for test cases.


Patch:
diff --git a/cmd/config.go b/cmd/config.go
index ae40111c..285cd938 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/andig/evcc/api"
 	"github.com/andig/evcc/charger"
-	"github.com/andig/evcc/core"
 	"github.com/andig/evcc/meter"
 	"github.com/andig/evcc/push"
 	"github.com/andig/evcc/server"
@@ -23,7 +22,7 @@ type config struct {
 	Meters     []qualifiedConfig
 	Chargers   []qualifiedConfig
 	Vehicles   []qualifiedConfig
-	LoadPoints []core.Config
+	LoadPoints []map[string]interface{}
 }
 
 type qualifiedConfig struct {
diff --git a/cmd/setup.go b/cmd/setup.go
index feac9fdb..693cdc3a 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -45,10 +45,10 @@ func loadConfig(conf config, eventsChan chan push.Event) (loadPoints []*core.Loa
 	}
 
 	// decode slice into slice of maps
-	var lpm []map[string]interface{}
-	util.DecodeOther(log, lps, &lpm)
+	var lpc []map[string]interface{}
+	util.DecodeOther(log, lps, &lpc)
 
-	for _, lpc := range lpm {
+	for _, lpc := range lpc {
 		lp := core.NewLoadPointFromConfig(log, cp, lpc)
 		loadPoints = append(loadPoints, lp)
 	}
diff --git a/core/chargerhandler.go b/core/chargerhandler.go
new file mode 100644
index 00000000..ea6b3c93
--- /dev/null
+++ b/core/chargerhandler.go
@@ -0,0 +1,143 @@
+package core
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/andig/evcc/api"
+
+	evbus "github.com/asaskevich/EventBus"
+	"github.com/benbjohnson/clock"
+)
+
+// ChargerHandler handles steering of the charger state and allowed current
+type ChargerHandler struct {
+	clock clock.Clock // mockable time
+	bus   evbus.Bus   // event bus
+
+	charger api.Charger // Charger
+
+	Name          string
+	Sensitivity   int64         // Step size of current change
+	MinCurrent    int64         // PV mode: start current	Min+PV mode: min current
+	MaxCurrent    int64         // Max allowed current. Physically ensured by the charge controller
+	GuardDuration time.Duration // charger enable/disable minimum holding time
+
+	enabled       bool // Charger enabled state
+	targetCurrent int64
+
+	// contactor switch guard
+	guardUpdated time.Time // charger enabled/disabled timestamp
+}
+
+// NewChargerHandler creates handler with default settings
+func NewChargerHandler(name string, clock clock.Clock, bus evbus.Bus) ChargerHandler {
+	return ChargerHandler{
+		Name:          name,
+		clock:         clock, // mockable time
+		bus:           bus,   // event bus
+		MinCurrent:    6,     // A
+		MaxCurrent:    16,    // A
+		Sensitivity:   10,    // A
+		GuardDuration: 5 * time.Minute,
+	}
+}
+
+// chargerEnable switches charging on or off. Minimum cycle duration is guaranteed.
+func (lp *ChargerHandler) chargerEnable(enable bool) error {
+	if lp.targetCurrent != 0 && lp.targetCurrent != lp.MinCurrent {
+		log.FATAL.Fatal("charger enable/disable called without setting min current first")
+	}
+
+	if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 {
+		log.DEBUG.Printf("%s charger %s - contactor delay %v", lp.Name, status[enable], remaining)
+		return nil
+	}
+
+	if lp.enabled != enable {
+		if err := lp.charger.Enable(enable); err != nil {
+			return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
+		}
+
+		lp.enabled = enable // cache
+		log.INFO.Printf("%s charger %s", lp.Name, status[enable])
+		lp.guardUpdated = lp.clock.Now()
+	} else {
+		log.DEBUG.Printf("%s charger %s", lp.Name, status[enable])
+	}
+
+	// if not enabled, current will be reduced to 0 in handler
+	lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
+
+	return nil
+}
+
+// setTargetCurrent guards setting current against changing to identical value
+// and violating MaxCurrent
+func (lp *ChargerHandler) setTargetCurrent(targetCurrentIn int64) error {
+	targetCurrent := clamp(targetCurrentIn, lp.MinCurrent, lp.MaxCurrent)
+	if targetCurrent != targetCurrentIn {
+		log.WARN.Printf("%s hard limit charge current: %dA", lp.Name, targetCurrent)
+	}
+
+	if lp.targetCurrent != targetCurrent {
+		log.DEBUG.Printf("%s set charge current: %dA", lp.Name, targetCurrent)
+		if err := lp.charger.MaxCurrent(targetCurrent); err != nil {
+			return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
+		}
+
+		lp.targetCurrent = targetCurrent // cache
+	}
+
+	lp.bus.Publish(evChargeCurrent, targetCurrent)
+
+	return nil
+}
+
+// rampUpDown moves stepwise towards target current.
+// It does not enable or disable the charger.
+func (lp *ChargerHandler) rampUpDown(target int64) error {
+	current := lp.targetCurrent
+	if current == target {
+		return nil
+	}
+
+	var step int64
+	if current < target {
+		step = min(current+lp.Sensitivity, target)
+	} else if current > target {
+		step = max(current-lp.Sensitivity, target)
+	}
+
+	step = clamp(step, lp.MinCurrent, lp.MaxCurrent)
+
+	return lp.setTargetCurrent(step)
+}
+
+// rampOff disables charger after setting minCurrent.
+// Setting current and disabling are two steps. If already disabled, this is a nop.
+func (lp *ChargerHandler) rampOff() error {
+	if lp.enabled {
+		if lp.targetCurrent == lp.MinCurrent {
+			return lp.chargerEnable(false)
+		}
+
+		return lp.setTargetCurrent(lp.MinCurrent)
+	}
+
+	return nil
+}
+
+// rampOn enables charger immediately after setting minCurrent.
+// If already enabled, target will be set.
+func (lp *ChargerHandler) rampOn(target int64) error {
+	if !lp.enabled {
+		if err := lp.setTargetCurrent(lp.MinCurrent); err != nil {
+			return err
+		}
+
+		return lp.chargerEnable(true)
+	}
+
+	return lp.setTargetCurrent(target)
+}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 71ed1848..cca544ac 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1,7 +1,6 @@
 package core
 
 import (
-	"fmt"
 	"math"
 	"sync"
 	"time"
@@ -36,22 +35,17 @@ func powerToCurrent(power, voltage float64, phases int64) int64 {
 
 // Config contains the public loadpoint configuration
 type Config struct {
-	Name string
 	Mode api.ChargeMode // Charge mode, guarded by mutex
 
 	// options
-	Sensitivity   int64   // Step size of current change
 	Phases        int64   // Phases- required for converting power and current.
-	MinCurrent    int64   // PV mode: start current	Min+PV mode: min current
-	MaxCurrent    int64   // Max allowed current. Physically ensured by the charge controller
 	Voltage       float64 // Operating voltage. 230V for Germany.
 	ResidualPower float64 // PV meter only: household usage. Grid meter: household safety margin
 
-	ChargerRef string       `mapstructure:"charger"` // Charger reference
-	VehicleRef string       `mapstructure:"vehicle"` // Vehicle reference
-	Meters     MetersConfig // Meter references
+	ChargerRef string `mapstructure:"charger"` // Charger reference
+	VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
 
-	GuardDuration time.Duration // charger enable/disable minimum holding time
+	Meters MetersConfig // Meter references
 }
 
 // MetersConfig contains the loadpoint's meter configuration
@@ -72,13 +66,13 @@ type LoadPoint struct {
 	notificationChan chan<- push.Event // notifications
 	uiChan           chan<- Param      // client push messages
 
-	Config `mapstructure:",squash"` // exposed public configuration
+	Config         `mapstructure:",squash"` // exposed public configuration
+	ChargerHandler `mapstructure:",squash"` // handle charger state and current
 
 	chargeTimer api.ChargeTimer
 	chargeRater api.ChargeRater
 
 	// meters
-	charger      api.Charger // Charger
 	gridMeter    api.Meter   // Grid usage meter
 	pvMeter      api.Meter   // PV generation meter
 	batteryMeter api.Meter   // Battery charging meter
@@ -86,17 +80,12 @@ type LoadPoint struct {
 	vehicle      api.Vehicle // Vehicle
 
 	// cached state
-	status        api.ChargeStatus // Charger status
-	targetCurrent int64            // Allowed current. Between MinCurrent and MaxCurrent.
-	enabled       bool             // Charger enabled state
-	charging      bool             // Charging cycle
-	gridPower     float64          // Grid power
-	pvPower       float64          // PV power
-	batteryPower  float64          // Battery charge power
-	chargePower   float64          // Charging power
-
-	// contactor switch guard
-	guardUpdated time.Time // charger enabled/disabled timestamp
+	status       api.ChargeStatus // Charger status
+	charging     bool             // Charging cycle
+	gridPower    float64          // Grid power
+	pvPower      float64          // PV power
+	batteryPower float64          // Battery charge power
+	chargePower  float64          // Charging power
 }
 
 // configProvider gives access to configuration repository
@@ -140,23 +129,23 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
 
 // NewLoadPoint creates a LoadPoint with sane defaults
 func NewLoadPoint() *LoadPoint {
-	return &LoadPoint{
-		clock:       clock.New(),
-		bus:         evbus.New(),
+	clock := clock.New()
+	bus := evbus.New()
+
+	lp := &LoadPoint{
+		clock:       clock, // mockable time
+		bus:         bus,   // event bus
 		triggerChan: make(chan struct{}, 1),
 		Config: Config{
-			Name:          "main",
-			Mode:          api.ModeOff,
-			Phases:        1,
-			Voltage:       230, // V
-			MinCurrent:    6,   // A
-			MaxCurrent:    16,  // A
-			Sensitivity:   10,  // A
-			GuardDuration: 10 * time.Minute,
+			Mode:    api.ModeOff,
+			Phases:  1,
+			Voltage: 230, // V
 		},
-		status:        api.StatusNone,
-		targetCurrent: 0, // A
+		status:         api.StatusNone,
+		ChargerHandler: NewChargerHandler("main", clock, bus),
 	}
+
+	return lp
 }
 
 // notify sends push messages to clients
@@ -302,32 +291,6 @@ func (lp *LoadPoint) connected() bool {
 	return lp.status == api.StatusB || lp.status == api.StatusC
 }
 
-// chargerEnable switches charging on or off. Minimum cycle duration is guaranteed.
-func (lp *LoadPoint) chargerEnable(enable bool) error {
-	if lp.targetCurrent != 0 && lp.targetCurrent != lp.MinCurrent {
-		log.FATAL.Fatal("charger enable/disable called without setting min current first")
-	}
-
-	if remaining := (lp.GuardDuration - time.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 {
-		log.DEBUG.Printf("%s charger %s - contactor delay %v", lp.Name, status[enable], remaining)
-		return nil
-	}
-
-	err := lp.charger.Enable(enable)
-	if err == nil {
-		lp.enabled = enable // cache
-		log.INFO.Printf("%s charger %s", lp.Name, status[enable])
-		lp.guardUpdated = lp.clock.Now()
-
-		// if not enabled, current will be reduced to 0 in handler
-		lp.bus.Publish(evChargeCurrent, lp.MinCurrent)
-	} else {
-		log.DEBUG.Printf("%s charger %s", lp.Name, status[enable])
-	}
-
-	return err
-}
-
 // chargingCycle detects charge cycle start and stop events and manages the
 // charge energy counter and charge timer. It guards against duplicate invocation.
 func (lp *LoadPoint) chargingCycle(enable bool) {
@@ -382,73 +345,6 @@ func (lp *LoadPoint) updateChargeStatus() api.ChargeStatus {
 	return status
 }
 
-// setTargetCurrent guards setting current against changing to identical value
-// and violating MaxCurrent
-func (lp *LoadPoint) setTargetCurrent(targetCurrentIn int64) error {
-	targetCurrent := clamp(targetCurrentIn, lp.MinCurrent, lp.MaxCurrent)
-	if targetCurrent != targetCurrentIn {
-		log.WARN.Printf("%s hard limit charge current: %dA", lp.Name, targetCurrent)
-	}
-
-	if lp.targetCurrent != targetCurrent {
-		log.DEBUG.Printf("%s set charge current: %dA", lp.Name, targetCurrent)
-		if err := lp.charger.MaxCurrent(targetCurrent); err != nil {
-			return fmt.Errorf("%s charge controller error: %v", lp.Name, err)
-		}
-
-		lp.targetCurrent = targetCurrent // cache
-	}
-
-	lp.bus.Publish(evChargeCurrent, targetCurrent)
-
-	return nil
-}
-
-// rampUpDown moves stepwise towards target current
-func (lp *LoadPoint) rampUpDown(target int64) error {
-	current := lp.targetCurrent
-	if current == target {
-		return nil
-	}
-
-	var step int64
-	if current < target {
-		step = min(current+lp.Sensitivity, target)
-	} else if current > target {
-		step = max(current-lp.Sensitivity, target)
-	}
-
-	step = clamp(step, lp.MinCurrent, lp.MaxCurrent)
-
-	return lp.setTargetCurrent(step)
-}
-
-// rampOff disables charger after setting minCurrent. If already disables, this is a nop.
-func (lp *LoadPoint) rampOff() error {
-	if lp.enabled {
-		if lp.targetCurrent == lp.MinCurrent {
-			return lp.chargerEnable(false)
-		}
-
-		return lp.setTargetCurrent(lp.MinCurrent)
-	}
-
-	return nil
-}
-
-// rampOn enables charger after setting minCurrent. If already enabled, target will be set.
-func (lp *LoadPoint) rampOn(target int64) error {
-	if !lp.enabled {
-		if err := lp.setTargetCurrent(lp.MinCurrent); err != nil {
-			return err
-		}
-
-		return lp.chargerEnable(true)
-	}
-
-	return lp.setTargetCurrent(target)
-}
-
 // updateModePV sets "minpv" or "pv" load modes
 func (lp *LoadPoint) updateModePV(mode api.ChargeMode) error {
 	// grid meter will always be available, if as wrapped pv meter


Test patch:
diff --git a/core/chargerhandler_test.go b/core/chargerhandler_test.go
new file mode 100644
index 00000000..48ff5886
--- /dev/null
+++ b/core/chargerhandler_test.go
@@ -0,0 +1,332 @@
+package core
+
+import (
+	"testing"
+	"time"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/mock"
+	evbus "github.com/asaskevich/EventBus"
+	"github.com/benbjohnson/clock"
+	"github.com/golang/mock/gomock"
+)
+
+const (
+	minA        int64 = 6
+	maxA        int64 = 16
+	sensitivity       = 1
+	dt                = time.Hour
+)
+
+func newChargerHandler(clock clock.Clock, mc api.Charger) ChargerHandler {
+	r := NewChargerHandler("", clock, evbus.New())
+
+	r.charger = mc
+	r.Sensitivity = sensitivity
+	r.guardUpdated = clock.Now()
+
+	return r
+}
+
+func TestNewChargerHandler(t *testing.T) {
+	r := NewChargerHandler("", nil, nil)
+
+	if r.MinCurrent != minA {
+		t.Errorf("expected %v, got %v", minA, r.MinCurrent)
+	}
+	if r.MaxCurrent != maxA {
+		t.Errorf("expected %v, got %v", maxA, r.MaxCurrent)
+	}
+	if r.Sensitivity != 10 {
+		t.Errorf("expected %v, got %v", 10, r.Sensitivity)
+	}
+	if r.GuardDuration != 5*time.Minute {
+		t.Errorf("expected %v, got %v", 5*time.Minute, r.GuardDuration)
+	}
+}
+
+func TestEnable(t *testing.T) {
+	tc := []struct {
+		enabledI       bool
+		dt             time.Duration
+		enable         bool
+		targetCurrentI int64
+		expect         func(*mock.MockCharger)
+	}{
+		// any test with current != 0 or min will fail
+		{false, 0, false, 0, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		{false, 0, true, 0, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		{false, dt, true, 0, func(mc *mock.MockCharger) {
+			mc.EXPECT().Enable(true).Return(nil)
+		}},
+		{false, 0, true, minA, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		{false, dt, true, minA, func(mc *mock.MockCharger) {
+			mc.EXPECT().Enable(true).Return(nil)
+		}},
+		{true, 0, false, minA, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		{true, dt, false, minA, func(mc *mock.MockCharger) {
+			mc.EXPECT().Enable(false).Return(nil)
+		}},
+		{true, 0, true, minA, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		{true, dt, true, minA, func(mc *mock.MockCharger) {
+			// nop
+		}},
+	}
+
+	for _, tc := range tc {
+		ctrl := gomock.NewController(t)
+		mc := mock.NewMockCharger(ctrl)
+
+		t.Log(tc)
+
+		clock := clock.NewMock()
+		r := newChargerHandler(clock, mc)
+		r.enabled = tc.enabledI
+		r.targetCurrent = tc.targetCurrentI
+
+		tc.expect(mc)
+		clock.Add(tc.dt)
+
+		if err := r.chargerEnable(tc.enable); err != nil {
+			t.Error(err)
+		}
+
+		ctrl.Finish()
+	}
+}
+
+func TestSetCurrent(t *testing.T) {
+	tc := []struct {
+		targetCurrentI, targetCurrent, targetCurrentO int64
+		expect                                        func(*mock.MockCharger)
+	}{
+		{0, 0, minA, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+		}},
+		{minA, minA, minA, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+		}},
+		{minA, 0, minA, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+		}},
+		{minA, maxA, maxA, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(maxA).Return(nil)
+		}},
+		{maxA, maxA, maxA, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+		}},
+		{minA, 2 * maxA, maxA, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(maxA).Return(nil)
+		}},
+	}
+
+	for _, tc := range tc {
+		ctrl := gomock.NewController(t)
+		mc := mock.NewMockCharger(ctrl)
+
+		t.Log(tc)
+
+		clock := clock.NewMock()
+		r := newChargerHandler(clock, mc)
+		r.targetCurrent = tc.targetCurrentI
+
+		tc.expect(mc)
+
+		if err := r.setTargetCurrent(tc.targetCurrent); err != nil {
+			t.Error(err)
+		}
+
+		if r.targetCurrent != tc.targetCurrentO {
+			t.Errorf("targetCurrent: expected %d, got %d", tc.targetCurrentO, r.targetCurrent)
+		}
+
+		ctrl.Finish()
+	}
+}
+
+func TestRampOn(t *testing.T) {
+	tc := []struct {
+		enabledI                      bool
+		targetCurrentI, targetCurrent int64
+		dt                            time.Duration
+		expect                        func(*mock.MockCharger)
+	}{
+		// off at zero: set min
+		{false, 0, minA, 0, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			// guard duration
+		}},
+		{false, 0, minA, dt, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			mc.EXPECT().Enable(true).Return(nil)
+		}},
+		// off at max: set min
+		{false, maxA, minA, 0, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			// guard duration
+		}},
+		{false, maxA, minA, dt, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			mc.EXPECT().Enable(true).Return(nil)
+		}},
+		// off at min: set on
+		{false, minA, minA, 0, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+			// guard duration
+		}},
+		{false, minA, minA, dt, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+			mc.EXPECT().Enable(true).Return(nil)
+		}},
+		// on at min, set min: set min
+		{true, minA, minA, 0, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+			// we are enabled: enable call omitted
+		}},
+		// on at max, set min: set min
+		{true, maxA, minA, 0, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			// we are enabled: enable call omitted
+		}},
+	}
+
+	for _, tc := range tc {
+		ctrl := gomock.NewController(t)
+		mc := mock.NewMockCharger(ctrl)
+
+		t.Log(tc)
+
+		clock := clock.NewMock()
+		r := newChargerHandler(clock, mc)
+		r.enabled = tc.enabledI
+		r.targetCurrent = tc.targetCurrentI
+
+		tc.expect(mc)
+		clock.Add(tc.dt)
+
+		if err := r.rampOn(tc.targetCurrent); err != nil {
+			t.Error(err)
+		}
+
+		ctrl.Finish()
+	}
+}
+
+func TestRampOff(t *testing.T) {
+	tc := []struct {
+		enabledI       bool
+		targetCurrentI int64
+		dt             time.Duration
+		expect         func(*mock.MockCharger)
+	}{
+		// off at zero
+		{false, 0, 0, func(mc *mock.MockCharger) {
+			// we are off: enable call omitted
+		}},
+		// off at min
+		{false, minA, 0, func(mc *mock.MockCharger) {
+			// we are off: enable call omitted
+		}},
+		// off at max
+		{false, maxA, 0, func(mc *mock.MockCharger) {
+			// we are off: enable call omitted
+		}},
+		// on at min, disable
+		{true, minA, 0, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+			// guard duration
+		}},
+		{true, minA, dt, func(mc *mock.MockCharger) {
+			// we are at min: current call omitted
+			mc.EXPECT().Enable(false).Return(nil)
+		}},
+		// on at max, set min
+		{true, maxA, 0, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA).Return(nil)
+			// we are not at min: enable call omitted
+		}},
+	}
+
+	for _, tc := range tc {
+		ctrl := gomock.NewController(t)
+		mc := mock.NewMockCharger(ctrl)
+
+		t.Log(tc)
+
+		clock := clock.NewMock()
+		r := newChargerHandler(clock, mc)
+		r.enabled = tc.enabledI
+		r.targetCurrent = tc.targetCurrentI
+
+		tc.expect(mc)
+		clock.Add(tc.dt)
+
+		if err := r.rampOff(); err != nil {
+			t.Error(err)
+		}
+
+		ctrl.Finish()
+	}
+}
+
+func TestRampUpDown(t *testing.T) {
+	tc := []struct {
+		targetCurrentI, targetCurrent int64
+		expect                        func(*mock.MockCharger)
+	}{
+		// no change at 0: nop
+		{0, 0, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		// no change at min: nop
+		{minA, minA, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		// at min: set <min
+		{minA, minA - 100, func(mc *mock.MockCharger) {
+			// nop
+		}},
+		// at min: set max
+		{minA, maxA, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(minA + sensitivity).Return(nil)
+		}},
+		// at max: set min
+		{maxA, minA, func(mc *mock.MockCharger) {
+			mc.EXPECT().MaxCurrent(maxA - sensitivity).Return(nil)
+		}},
+		// at max: set >max
+		{maxA, maxA + 100, func(mc *mock.MockCharger) {
+			// nop
+		}},
+	}
+
+	for _, tc := range tc {
+		ctrl := gomock.NewController(t)
+		mc := mock.NewMockCharger(ctrl)
+
+		t.Log(tc)
+
+		clock := clock.NewMock()
+		r := newChargerHandler(clock, mc)
+		r.enabled = true
+		r.targetCurrent = tc.targetCurrentI
+
+		tc.expect(mc)
+
+		if err := r.rampUpDown(tc.targetCurrent); err != nil {
+			t.Error(err)
+		}
+
+		ctrl.Finish()
+	}
+}