Repository: evcc-io/evcc
Pull request #2337 (2022-01-18 15:02:55)

Add soc push event every 10% charge progress
Fix https://github.com/evcc-io/evcc/issues/2306


Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index 4ff6e799..80ce222b 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -1,6 +1,11 @@
 name: Default
 
-on: [push, pull_request, workflow_call]
+on: 
+  push:
+    branches:
+      - master
+  pull_request:
+  workflow_call:
 
 jobs:
 
diff --git a/charger/openwb.go b/charger/openwb.go
index 9685fb99..40689835 100644
--- a/charger/openwb.go
+++ b/charger/openwb.go
@@ -60,39 +60,18 @@ func NewOpenWB(log *util.Logger, mqttconf mqtt.Config, id int, topic string, p1p
 	}
 
 	// timeout handler
-	timer := provider.NewMqtt(log, client,
+	to := provider.NewTimeoutHandler(provider.NewMqtt(log, client,
 		fmt.Sprintf("%s/system/%s", topic, openwb.TimestampTopic), 1, timeout,
-	).IntGetter()
+	).StringGetter())
 
-	// getters
 	boolG := func(topic string) func() (bool, error) {
 		g := provider.NewMqtt(log, client, topic, 1, 0).BoolGetter()
-		return func() (val bool, err error) {
-			if val, err = g(); err == nil {
-				_, err = timer()
-			}
-			return val, err
-		}
+		return to.BoolGetter(g)
 	}
 
-	// intG := func(topic string) func() (int64, error) {
-	// 	g := provider.NewMqtt(log, client, topic, 1, 0).IntGetter()
-	// 	return func() (val int64, err error) {
-	// 		if val, err = g(); err == nil {
-	// 			_, err = timer()
-	// 		}
-	// 		return val, err
-	// 	}
-	// }
-
 	floatG := func(topic string) func() (float64, error) {
 		g := provider.NewMqtt(log, client, topic, 1, 0).FloatGetter()
-		return func() (val float64, err error) {
-			if val, err = g(); err == nil {
-				_, err = timer()
-			}
-			return val, err
-		}
+		return to.FloatGetter(g)
 	}
 
 	// check if loadpoint configured
diff --git a/charger/warp.go b/charger/warp.go
index 131a9e3e..8720bd3c 100644
--- a/charger/warp.go
+++ b/charger/warp.go
@@ -92,18 +92,13 @@ func NewWarp(mqttconf mqtt.Config, topic string, timeout time.Duration) (*Warp,
 	}
 
 	// timeout handler
-	timer := provider.NewMqtt(log, client,
+	to := provider.NewTimeoutHandler(provider.NewMqtt(log, client,
 		fmt.Sprintf("%s/evse/state", topic), 1, timeout,
-	).StringGetter()
+	).StringGetter())
 
 	stringG := func(topic string) func() (string, error) {
 		g := provider.NewMqtt(log, client, topic, 1, 0).StringGetter()
-		return func() (val string, err error) {
-			if val, err = g(); err == nil {
-				_, err = timer()
-			}
-			return val, err
-		}
+		return to.StringGetter(g)
 	}
 
 	wb.enabledG = stringG(fmt.Sprintf("%s/evse/auto_start_charging", topic))
diff --git a/cmd/dumper.go b/cmd/dumper.go
index 41edb626..f938ac88 100644
--- a/cmd/dumper.go
+++ b/cmd/dumper.go
@@ -145,7 +145,7 @@ func (d *dumper) Dump(name string, v interface{}) {
 		if ft, err := v.FinishTime(); err != nil {
 			fmt.Fprintf(w, "Finish time:\t%v\n", err)
 		} else {
-			fmt.Fprintf(w, "Finish time:\t%v\n", ft.Truncate(time.Minute))
+			fmt.Fprintf(w, "Finish time:\t%v\n", ft.Truncate(time.Minute).In(time.Local))
 		}
 	}
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index b542eab5..055a291a 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -31,6 +31,7 @@ const (
 	evChargePower       = "power"      // update chargeRater
 	evVehicleConnect    = "connect"    // vehicle connected
 	evVehicleDisconnect = "disconnect" // vehicle disconnected
+	evVehicleSoC        = "soc"        // vehicle soc progress
 
 	pvTimer   = "pv"
 	pvEnable  = "enable"
@@ -147,6 +148,7 @@ type LoadPoint struct {
 	chargedEnergy           float64       // Charged energy while connected in Wh
 	chargeRemainingDuration time.Duration // Remaining charge duration
 	chargeRemainingEnergy   float64       // Remaining charge energy in Wh
+	progress                *Progress     // Step-wise progress indicator
 }
 
 // NewLoadPointFromConfig creates a new loadpoint
@@ -254,6 +256,7 @@ func NewLoadPoint(log *util.Logger) *LoadPoint {
 		MaxCurrent:    16,                             // A
 		SoC:           SoCConfig{Min: 0, Target: 100}, // %
 		GuardDuration: 5 * time.Minute,
+		progress:      NewProgress(0, 10), // soc progress indicator
 	}
 
 	return lp
@@ -431,6 +434,13 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 	lp.socTimer.Reset()
 }
 
+// evVehicleSoCProgressHandler sends external start event
+func (lp *LoadPoint) evVehicleSoCProgressHandler(soc float64) {
+	if lp.progress.NextStep(soc) {
+		lp.pushEvent(evVehicleSoC)
+	}
+}
+
 // evChargeCurrentHandler publishes the charge current
 func (lp *LoadPoint) evChargeCurrentHandler(current float64) {
 	if !lp.enabled {
@@ -495,6 +505,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	_ = lp.bus.Subscribe(evVehicleConnect, lp.evVehicleConnectHandler)
 	_ = lp.bus.Subscribe(evVehicleDisconnect, lp.evVehicleDisconnectHandler)
 	_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentHandler)
+	_ = lp.bus.Subscribe(evVehicleSoC, lp.evVehicleSoCProgressHandler)
 
 	// publish initial values
 	lp.publish("title", lp.Title)
@@ -775,6 +786,8 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
 		lp.applyAction(vehicle.OnIdentified())
 
 		lp.setVehiclePhases()
+
+		lp.progress.Reset()
 	} else {
 		lp.socEstimator = nil
 
@@ -1346,6 +1359,9 @@ func (lp *LoadPoint) publishSoCAndRange() {
 					lp.publish("vehicleOdometer", odo)
 				}
 			}
+
+			// trigger message after variables are updated
+			lp.bus.Publish(evVehicleSoC, f)
 		} else {
 			if errors.Is(err, api.ErrMustRetry) {
 				lp.socUpdated = time.Time{}
diff --git a/core/progress.go b/core/progress.go
new file mode 100644
index 00000000..73b0268e
--- /dev/null
+++ b/core/progress.go
@@ -0,0 +1,29 @@
+package core
+
+type Progress struct {
+	min, step, current float64
+}
+
+func NewProgress(min, step float64) *Progress {
+	return &Progress{
+		min:     min,
+		step:    step,
+		current: min,
+	}
+}
+
+func (p *Progress) NextStep(value float64) bool {
+	if value >= p.current {
+		for p.current <= value {
+			p.current += p.step
+		}
+
+		return true
+	}
+
+	return false
+}
+
+func (p *Progress) Reset() {
+	p.current = p.min
+}
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index a825b0c0..8c8927f3 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -166,6 +166,9 @@ messaging:
     disconnect: # vehicle connected event
       title: Car disconnected
       msg: Car disconnected after ${connectedDuration}
+    soc: # vehicle soc update event
+      title: SoC updated
+      msg: Battery charged to ${vehicleSoC:%.0f}%
   services:
   # - type: pushover
   #   app: # app id
diff --git a/go.mod b/go.mod
index ca4ac84d..c337ab08 100644
--- a/go.mod
+++ b/go.mod
@@ -83,6 +83,7 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.1
+	github.com/stretchr/testify v1.7.0
 	github.com/thoas/go-funk v0.9.1
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
 	github.com/volkszaehler/mbmd v0.0.0-20220108103619-de9b2cf95ebe
diff --git a/meter/openwb.go b/meter/openwb.go
index cfce7750..b13f6c9c 100644
--- a/meter/openwb.go
+++ b/meter/openwb.go
@@ -41,33 +41,18 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
 	}
 
 	// timeout handler
-	timer := provider.NewMqtt(log, client,
+	to := provider.NewTimeoutHandler(provider.NewMqtt(log, client,
 		fmt.Sprintf("%s/system/%s", cc.Topic, openwb.TimestampTopic), 1, cc.Timeout,
-	).IntGetter()
+	).StringGetter())
 
-	// getters
 	boolG := func(topic string) func() (bool, error) {
 		g := provider.NewMqtt(log, client, topic, 1, 0).BoolGetter()
-		return func() (val bool, err error) {
-			if val, err = g(); err == nil {
-				_, err = timer()
-			}
-			return val, err
-		}
+		return to.BoolGetter(g)
 	}
 
-	floatG := func(topic string, scaler ...float64) func() (float64, error) {
-		scale := 1.0
-		if len(scaler) == 1 {
-			scale = scaler[0]
-		}
+	floatG := func(topic string) func() (float64, error) {
 		g := provider.NewMqtt(log, client, topic, 1, 0).FloatGetter()
-		return func() (val float64, err error) {
-			if val, err = g(); err == nil {
-				_, err = timer()
-			}
-			return scale * val, err
-		}
+		return to.FloatGetter(g)
 	}
 
 	var power func() (float64, error)
@@ -87,7 +72,7 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
 		currents = collectCurrentProviders(curr)
 
 	case "pv":
-		configuredG := boolG(fmt.Sprintf("%s/pv/%s", cc.Topic, openwb.PvConfigured))
+		configuredG := boolG(fmt.Sprintf("%s/pv/1/%s", cc.Topic, openwb.PvConfigured)) // first pv
 		configured, err := configuredG()
 		if err != nil {
 			return nil, err
@@ -110,7 +95,7 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
 			return nil, errors.New("battery not available")
 		}
 
-		power = floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.PowerTopic), -1)
+		power = floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.PowerTopic))
 		soc = floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.SoCTopic))
 
 	default:
diff --git a/provider/mqtt_timeout.go b/provider/mqtt_timeout.go
new file mode 100644
index 00000000..1a3d7103
--- /dev/null
+++ b/provider/mqtt_timeout.go
@@ -0,0 +1,37 @@
+package provider
+
+// TimeoutHandler is a wrapper for a Getter that times out after a given duration
+type TimeoutHandler struct {
+	ticker func() (string, error)
+}
+
+func NewTimeoutHandler(ticker func() (string, error)) *TimeoutHandler {
+	return &TimeoutHandler{ticker}
+}
+
+func (h *TimeoutHandler) BoolGetter(g func() (bool, error)) func() (bool, error) {
+	return func() (val bool, err error) {
+		if val, err = g(); err == nil {
+			_, err = h.ticker()
+		}
+		return val, err
+	}
+}
+
+func (h *TimeoutHandler) FloatGetter(g func() (float64, error)) func() (float64, error) {
+	return func() (val float64, err error) {
+		if val, err = g(); err == nil {
+			_, err = h.ticker()
+		}
+		return val, err
+	}
+}
+
+func (h *TimeoutHandler) StringGetter(g func() (string, error)) func() (string, error) {
+	return func() (val string, err error) {
+		if val, err = g(); err == nil {
+			_, err = h.ticker()
+		}
+		return val, err
+	}
+}
diff --git a/vehicle/fiat/api.go b/vehicle/fiat/api.go
index a0295159..372e92f6 100644
--- a/vehicle/fiat/api.go
+++ b/vehicle/fiat/api.go
@@ -92,6 +92,19 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 	return res, err
 }
 
+func (v *API) Location(vin string) (LocationResponse, error) {
+	var res LocationResponse
+
+	uri := fmt.Sprintf("%s/v1/accounts/%s/vehicles/%s/location/lastknown", ApiURI, v.identity.UID(), vin)
+
+	req, err := v.request(http.MethodGet, uri, nil)
+	if err == nil {
+		err = v.DoJSON(req, &res)
+	}
+
+	return res, err
+}
+
 func (v *API) pinAuth(pin string) (string, error) {
 	var res PinAuthResponse
 
diff --git a/vehicle/fiat/provider.go b/vehicle/fiat/provider.go
index f32dcfd2..2322862f 100644
--- a/vehicle/fiat/provider.go
+++ b/vehicle/fiat/provider.go
@@ -12,6 +12,7 @@ const refreshTimeout = 2 * time.Minute
 
 type Provider struct {
 	statusG     func() (interface{}, error)
+	locationG   func() (interface{}, error)
 	action      func(action, cmd string) (ActionResponse, error)
 	expiry      time.Duration
 	refreshTime time.Time
@@ -22,6 +23,9 @@ func NewProvider(api *API, vin, pin string, expiry, cache time.Duration) *Provid
 		statusG: provider.NewCached(func() (interface{}, error) {
 			return api.Status(vin)
 		}, cache).InterfaceGetter(),
+		locationG: provider.NewCached(func() (interface{}, error) {
+			return api.Location(vin)
+		}, cache).InterfaceGetter(),
 		action: func(action, cmd string) (ActionResponse, error) {
 			return api.Action(vin, pin, action, cmd)
 		},
@@ -145,3 +149,15 @@ func (v *Provider) Status() (api.ChargeStatus, error) {
 
 	return status, err
 }
+
+var _ api.VehiclePosition = (*Provider)(nil)
+
+// Position implements the api.VehiclePosition interface
+func (v *Provider) Position() (float64, float64, error) {
+	res, err := v.locationG()
+	if res, ok := res.(LocationResponse); err == nil && ok {
+		return res.Latitude, res.Latitude, nil
+	}
+
+	return 0, 0, err
+}
diff --git a/vehicle/fiat/types.go b/vehicle/fiat/types.go
index a1632975..c0e300d6 100644
--- a/vehicle/fiat/types.go
+++ b/vehicle/fiat/types.go
@@ -42,6 +42,15 @@ type StatusResponse struct {
 	Timestamp TimeMillis
 }
 
+type LocationResponse struct {
+	TimeStamp        TimeMillis
+	Longitude        float64
+	Latitude         float64
+	Altitude         float64
+	Bearing          float64
+	IsLocationApprox bool
+}
+
 type ActionResponse struct {
 	Name, Message string
 


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 1928081e..387e0797 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -379,9 +379,10 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 		bus:          evbus.New(),
 		clock:        clock,
 		charger:      charger,
-		chargeMeter:  &Null{}, // silence nil panics
-		chargeRater:  &Null{}, // silence nil panics
-		chargeTimer:  &Null{}, // silence nil panics
+		chargeMeter:  &Null{},            // silence nil panics
+		chargeRater:  &Null{},            // silence nil panics
+		chargeTimer:  &Null{},            // silence nil panics
+		progress:     NewProgress(0, 10), // silence nil panics
 		MinCurrent:   minA,
 		MaxCurrent:   maxA,
 		vehicle:      vehicle,      // needed for targetSoC check
diff --git a/core/progress_test.go b/core/progress_test.go
new file mode 100644
index 00000000..1ebbfe7c
--- /dev/null
+++ b/core/progress_test.go
@@ -0,0 +1,33 @@
+package core
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestProgress(t *testing.T) {
+	p := NewProgress(0, 10)
+
+	tc := []struct {
+		value float64
+		res   bool
+	}{
+		{-1, false},
+		{0, true},
+		{1, false},
+		{5, false},
+		{10, true},
+		{15, false},
+		{25, true},
+		{30, true},
+		{60, true},
+		{65, false},
+		{70, true},
+	}
+
+	for _, tc := range tc {
+		require.Equal(t, tc.res, p.NextStep(tc.value), fmt.Sprintf("%.0f%%", tc.value))
+	}
+}