Repository: andig/evcc
Pull request #277 (2020-08-11 13:48:50)

SOC-Interpolation
Als Diskussionsgrundlage zu #255 und #276
Soweit mal getestet und für grundlegend funktionsfähig befunden.

premultiply: Insbesondere bei der Intialisierung bin ich mir noch sehr unsicher und brauche da mal ein Feedback.
Ich freue mich mich aber über eure Kritik und Verbesserungsvorschläge oder sonstwie jegliche Kommentare.

Es funktioniert so, dass zunächst aus der Konfig die vom Nutzer angebene Batteriekapazität genutzt wird um die SOC-Steigungsrate pro geladener Wh zu berechnen. Liegt dann ein erster SOC-Wert der API vor wird die gemessene geladene Energie genutzt um den SOC weiter anhand der Steigungsrate zu erhöhen. Beim jeweils nächsten abweichenden SOC-Wert von der API wird die Steigungsrate dynamisch neu berechnet und daher die Wachstumsrate immer wieder dem tatsächlichen Bedarf angepasst.
premultiply: Funktioniert schon prächtig. :D


```
[lp-1  ] TRACE 2020/08/12 11:28:03 chargedEnergy: 3281Wh, chargedEnergyAtSocUpdate: 2034Wh, energyPerSocStep: 551Wh, virtualBatCap: 55.1kWh
[lp-1  ] TRACE 2020/08/12 11:28:03 last vehicle api soc: 74.00%, estimated soc: 76.27%
...
[lp-1  ] TRACE 2020/08/12 11:28:21 chargedEnergy: 3293Wh, chargedEnergyAtSocUpdate: 3293Wh, energyPerSocStep: 630Wh, virtualBatCap: 63.0kWh
[lp-1  ] TRACE 2020/08/12 11:28:21 last vehicle api soc: 76.00%, estimated soc: 76.00%
```

Da sieht man auch schön, dass die "virtuelle Batteriekapazität" (also inkl. "Ladeverlusten") u. U. weit über der Prospektangabe liegt.
Natürlich ist bei kleinen Leistungen und damit auch kleinen SOC-Sprüngen die Fehlerstreuung der Prognose höher aber da brauchen auch die Nebensysteme auch einen deutlichen Eigenanteil.

Naja bei Überschuss ist es halt auch relativ egal.

Sehe noch ein paar kleine Optimierungsmöglichkeiten die ich noch einbauen werde.
premultiply: Gerne mal testen, optimieren und kommentieren.
premultiply: Uh, oh. 100% SOC Limit fehlt noch... :D
schenlap: Ich habe es heute getestet, hat sehr gut funktioniert.
Ich denke damit kann mein PR geschlossen werden.

Zwei Dinge sind mir noch aufgefallen:
1) Beschränken auf 100%, hast du ja auch schon festgestellt
2) Die geschätzte Ladedauer scheint den Wert noch nicht zu verwenden., zumindest ändert sich dieser nicht. Das war aber bei meinen PR auch noch so.
premultiply: @andig Magst du wirklich keinen Float-SOC?
Nicht so ein Fancy-Smooth-Scrolling-Balken-Gedöhns?
schenlap: Beim Beenden der Ladung bricht der SOC plötzlich ein :-)

![image](https://user-images.githubusercontent.com/20611587/90259646-a9722e00-de4a-11ea-9b60-bcf860cfac3b.png)

SOC Update vom Fahrzeig war bei 40% und bei 50%. Kurz nach 50% bin ich auf Stopp gegangen und dann ist der berechnete SOC auf 45% gesprungen. Ich verstehe allerdings nicht wieso das so ist.

```
cache ] TRACE 2020/08/14 16:14:14 chargedEnergy: 960                                                                        
[lp-1  ] TRACE 2020/08/14 16:14:14 chargedEnergy: 960Wh, chargedEnergyAtSocUpdate: 850Wh, energyPerSocStep: 85Wh, virtualBatC
ap: 8.5kWh                                                                                                                   
[cache ] TRACE 2020/08/14 16:14:14 chargeDuration: 42m45s                                                                    
[lp-1  ] TRACE 2020/08/14 16:14:14 last vehicle api soc: 50.00%, estimated soc: 51.29%                                       <- da paßt es noch
[cache ] TRACE 2020/08/14 16:14:14 socCharge: 51.294117647058826                                                             
[cache ] TRACE 2020/08/14 16:14:14 chargeEstimate: 2h53m55s                                                                  
[wifi  ] TRACE 2020/08/14 16:14:14 http://10.0.0.208/getParameters                                                           
{"type":"parameters","list":[{"vehicleState":3,"evseState":true,"maxCurrent":16,"actualCurrent":6,"actualPower":1.32,"duratio
n":2564530,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0.96,"mileage":5.3,"meterReading":0.96,
"currentP1":5.79,"currentP2":0,"currentP3":0}]}                                                                              
[lp-1  ] DEBUG 2020/08/14 16:14:14 charger status: C                                                                         
[cache ] TRACE 2020/08/14 16:14:14 connected: true                                                                           
[cache ] TRACE 2020/08/14 16:14:14 charging: true                                                                            
[wifi  ] TRACE 2020/08/14 16:14:14 http://10.0.0.208/getParameters                                                           
{"type":"parameters","list":[{"vehicleState":3,"evseState":true,"maxCurrent":16,"actualCurrent":6,"actualPower":1.32,"duratio
n":2564552,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0.96,"mileage":5.3,"meterReading":0.96,
"currentP1":5.79,"currentP2":0,"currentP3":0}]}
[wifi  ] TRACE 2020/08/14 16:14:14 http://10.0.0.208/setStatus?active=false
S0_EVSE successfully deactivated
[lp-1  ] INFO 2020/08/14 16:14:14 charger disable                            <- jetzt wird ausgeschaltet
[cache ] TRACE 2020/08/14 16:14:14 chargeCurrent: 6
[core  ] DEBUG 2020/08/14 16:14:20 ----
[core  ] DEBUG 2020/08/14 16:14:20 pv power: 428W
[core  ] DEBUG 2020/08/14 16:14:20 grid power: -90W
[core  ] DEBUG 2020/08/14 16:14:20 site power: -90W
[lp-1  ] DEBUG 2020/08/14 16:14:20 charge power: 0W
[cache ] TRACE 2020/08/14 16:14:20 pvPower: 428
[cache ] TRACE 2020/08/14 16:14:20 gridPower: -90
[cache ] TRACE 2020/08/14 16:14:20 mode: off
[cache ] TRACE 2020/08/14 16:14:20 chargePower: 0
[cache ] TRACE 2020/08/14 16:14:20 chargeCurrent: 6
[wifi  ] TRACE 2020/08/14 16:14:20 http://10.0.0.208/getParameters
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}
[wifi  ] TRACE 2020/08/14 16:14:20 http://10.0.0.208/getParameters
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}
[lp-1  ] TRACE 2020/08/14 16:14:20 chargedEnergy: 0Wh, chargedEnergyAtSocUpdate: 850Wh, energyPerSocStep: 85Wh, virtualBatCap
: 8.5kWh
[lp-1  ] TRACE 2020/08/14 16:14:20 last vehicle api soc: 50.00%, estimated soc: 40.00%      <-   estimated ist 40%, wieso?
[cache ] TRACE 2020/08/14 16:14:20 chargedEnergy: 0
[cache ] TRACE 2020/08/14 16:14:20 chargeDuration: 0s
[cache ] TRACE 2020/08/14 16:14:20 socCharge: 40
[cache ] TRACE 2020/08/14 16:14:20 chargeEstimate: -1ns
[wifi  ] TRACE 2020/08/14 16:14:20 http://10.0.0.208/getParameters
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}
[lp-1  ] DEBUG 2020/08/14 16:14:20 charger status: B
[lp-1  ] INFO 2020/08/14 16:14:20 stop charging <-
[cache ] TRACE 2020/08/14 16:14:20 chargeCurrent: 6
[cache ] TRACE 2020/08/14 16:14:20 connected: true
[cache ] TRACE 2020/08/14 16:14:20 charging: false
[wifi  ] TRACE 2020/08/14 16:14:20 http://10.0.0.208/getParameters
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}
```
Soc in der GUI ist 45.6% und bleibst auch dabei.
```
[lp-1  ] TRACE 2020/08/14 16:18:00 chargedEnergy: 0Wh, chargedEnergyAtSocUpdate: 850Wh, energyPerSocStep: 85Wh, virtualBatCap
: 8.5kWh                                                                                                                     
[lp-1  ] TRACE 2020/08/14 16:18:00 last vehicle api soc: 50.00%, estimated soc: 40.00%                                       
[cache ] TRACE 2020/08/14 16:18:00 chargedEnergy: 0                                                                          
[cache ] TRACE 2020/08/14 16:18:00 chargeDuration: 0s                                                                        
[cache ] TRACE 2020/08/14 16:18:00 socCharge: 40                                                                             
[cache ] TRACE 2020/08/14 16:18:00 chargeEstimate: -1ns                                                                      
[wifi  ] TRACE 2020/08/14 16:18:00 http://10.0.0.208/getParameters                                                           
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}                                                                                              
[lp-1  ] DEBUG 2020/08/14 16:18:00 charger status: B                                                                         
[cache ] TRACE 2020/08/14 16:18:00 connected: true                                                                           
[cache ] TRACE 2020/08/14 16:18:00 charging: false    
```
premultiply: War das mit der letzten Version/Commit des PR?

Ursache ist jedenfalls dass die geladene Energie des Ladevorgangs auf 0 zurückgesetzt wird.
andig: Solche komplexen Funktionen sollten im Idealfall mit einem Unittest hinterlegt sein
schenlap: Ich glaub cih weiß jetzt wo es herkommt. Die Wallbox setzt den Wert zurück. Das sieht man wenn man ohben im Log sich die ursprüngliche Version ansieht, ich habe dann nämlich die TRACE Eintrage der Wallbox gelöscht damit es übersichtlicher wird ;-)

Hier der relavante Teil:
```
[wifi  ] TRACE 2020/08/14 16:14:14 http://10.0.0.208/getParameters  
{"type":"parameters","list":[{"vehicleState":3,"evseState":true,"maxCurrent":16,"actualCurrent":6,"actualPower":1.32,"duratio
n":2564552,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0.96,"mileage":5.3,"meterReading":0.96,
"currentP1":5.79,"currentP2":0,"currentP3":0}]}
-> ausschalten
[wifi  ] TRACE 2020/08/14 16:14:20 http://10.0.0.208/getParameters
{"type":"parameters","list":[{"vehicleState":2,"evseState":false,"maxCurrent":16,"actualCurrent":6,"actualPower":0,"duration"
:0,"alwaysActive":false,"lastActionUser":"API","lastActionUID":"API","energy":0,"mileage":0,"meterReading":0.96,"currentP1":0
,"currentP2":0,"currentP3":0}]}
```
Was ich aber trotzdem nciht verstehe warum dann nicht der Wert des letzten vom Fahrzeug empfangenen SOC ausgiebt, also 50%?
schenlap: >War das mit der letzten Version/Commit des PR?

Ja
premultiply: Die Rücksetzung des Ladezählers ist nicht vorgesehen. Der darf nur vorwärts laufen.
Muss ich das hier abfangen oder wird das an anderer Stelle gerade gerückt. Das dürfte nämlich auch noch zu weiteren merkwürdigen Effekten führen.

Der Ladezähler (Gesamtenergie) der WB sollte eigentlich wie jeder andere Energiezähler immer nur vorwärts laufen. Die Differenzbildung macht EVCC dann selber.
Wird da vielleicht ein falscher Wert abgefragt?
andig: @premultiply ich hab den Code kommentiert. Sehr viele neue globale Variablen für den Zweck, aber das ist wohl nicht zu vermeiden. Ein Testfall wäre gut (in einer xyz_test Datei). Damit das gut testbar ist könntest Du den neuen Code in eine `interpolateSoC()` Funktion auslagern die- mit entsprechenden Parametern aufgerufen- einzeln getestet werden könnte.
andig: @premultiply schau mal bitte die #286 an- so in der Art könnte das in eine eigene Datei wandern. Würdest Du das auf der Basis fertig machen wollen?

Patch:
diff --git a/Dockerfile b/Dockerfile
index fd47442e..b14c93dc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,16 +23,18 @@ RUN make clean install assets build
 # STEP 2 build a small image including module support
 #
 
-FROM alpine:3.11
+FROM alpine:3.12
 
 WORKDIR /evcc
 
+ENV TZ=Europe/Berlin
+
 # Import from builder
 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 COPY --from=builder /build/evcc /usr/local/bin/evcc
 
-COPY bin/* /evcc/
+COPY docker/bin/* /evcc/
 
 # UI and /api
 EXPOSE 7070/tcp
diff --git a/Makefile b/Makefile
index ca6f3cd7..af606eca 100644
--- a/Makefile
+++ b/Makefile
@@ -1,11 +1,13 @@
-.PHONY: default clean install lint test assets build binaries publish-images test-release release
+.PHONY: default clean install lint test assets build binaries test-release release publish-testing publish-latest publish-images
 
 TAG_NAME := $(shell git describe --abbrev=0 --tags)
 SHA := $(shell git rev-parse --short HEAD)
 VERSION := $(if $(TAG_NAME),$(TAG_NAME),$(SHA))
-
 BUILD_DATE := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
 
+ALPINE := 3.12
+TARGETS := arm.v6,arm.v8,amd64
+
 default: clean install assets lint test build
 
 clean:
@@ -31,20 +33,23 @@ build:
 	@echo Version: $(VERSION) $(BUILD_DATE)
 	go build -v -tags=release -ldflags '-X "github.com/andig/evcc/server.Version=${VERSION}" -X "github.com/andig/evcc/server.Commit=${SHA}"'
 
-test-images:
-	@echo Version: $(VERSION) $(BUILD_DATE)
-	seihon publish -v "testing" --image-name andig/evcc --base-runtime-image alpine --dry-run=false --targets=amd64
+release-test:
+	goreleaser --snapshot --skip-publish --rm-dist
 
-publish-images:
+release:
+	goreleaser --rm-dist
+
+publish-testing:
 	@echo Version: $(VERSION) $(BUILD_DATE)
-	seihon publish -v "$(TAG_NAME)" -v "latest" --image-name andig/evcc --base-runtime-image alpine --dry-run=false --targets=arm.v6,arm.v8,amd64
+	seihon publish --dry-run=false --template docker/tmpl.Dockerfile --base-runtime-image alpine:$(ALPINE) \
+	   --image-name andig/evcc -v "testing" --targets=amd64
 
 publish-latest:
 	@echo Version: $(VERSION) $(BUILD_DATE)
-	seihon publish -v "latest" --image-name andig/evcc --base-runtime-image alpine --dry-run=false --targets=arm.v6,arm.v8,amd64
-
-test-release:
-	goreleaser --snapshot --skip-publish --rm-dist
+	seihon publish --dry-run=false --template docker/tmpl.Dockerfile --base-runtime-image alpine:$(ALPINE) \
+	   --image-name andig/evcc -v "latest" --targets=$(TARGETS)
 
-release:
-	goreleaser --rm-dist
+publish-images:
+	@echo Version: $(VERSION) $(BUILD_DATE)
+	seihon publish --dry-run=false --template docker/tmpl.Dockerfile --base-runtime-image alpine:$(ALPINE) \
+	   --image-name andig/evcc -v "latest" -v "$(TAG_NAME)" --targets=$(TARGETS)
diff --git a/charger/keba.go b/charger/keba.go
index f886c8d2..aaf265eb 100644
--- a/charger/keba.go
+++ b/charger/keba.go
@@ -40,33 +40,36 @@ type Keba struct {
 func NewKebaFromConfig(other map[string]interface{}) (api.Charger, error) {
 	cc := struct {
 		URI     string
+		Serial  string
 		Timeout time.Duration
 		RFID    RFID
-	}{}
+	}{
+		Timeout: udpTimeout,
+	}
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
 
-	return NewKeba(cc.URI, cc.RFID, cc.Timeout)
+	return NewKeba(cc.URI, cc.Serial, cc.RFID, cc.Timeout)
 }
 
 // NewKeba creates a new charger
-func NewKeba(conn string, rfid RFID, timeout time.Duration) (api.Charger, error) {
+func NewKeba(conn, serial string, rfid RFID, timeout time.Duration) (api.Charger, error) {
 	log := util.NewLogger("keba")
 
+	var err error
 	if keba.Instance == nil {
-		keba.Instance = keba.New(log, fmt.Sprintf(":%s", kebaPort))
+		keba.Instance, err = keba.New(log, fmt.Sprintf(":%s", kebaPort))
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	// add default port
-	if _, _, err := net.SplitHostPort(conn); err != nil {
+	if _, _, err = net.SplitHostPort(conn); err != nil {
 		conn = fmt.Sprintf("%s:%s", conn, kebaPort)
 	}
 
-	if timeout == 0 {
-		timeout = udpTimeout
-	}
-
 	c := &Keba{
 		log:     log,
 		conn:    conn,
@@ -75,9 +78,12 @@ func NewKeba(conn string, rfid RFID, timeout time.Duration) (api.Charger, error)
 		recv:    make(chan keba.UDPMsg),
 	}
 
-	keba.Instance.Subscribe(conn, c.recv)
+	// use serial to subscribe if defined for docker scenarios
+	if serial == "" {
+		serial = conn
+	}
 
-	return c, nil
+	return c, keba.Instance.Subscribe(serial, c.recv)
 }
 
 func (c *Keba) send(msg string) error {
@@ -226,32 +232,43 @@ func (c *Keba) Enable(enable bool) error {
 		d = 1
 	}
 
+	// ignore result...
 	var resp string
-	err := c.roundtrip(fmt.Sprintf("ena %d", d), 0, &resp)
-	if err != nil {
-		return err
+	_ = c.roundtrip(fmt.Sprintf("ena %d", d), 0, &resp)
+
+	// ...and verify value
+	res, err := c.Enabled()
+	if err == nil && res != enable {
+		return fmt.Errorf("ena could not enable: %s", resp)
 	}
 
-	if string(resp) == keba.OK {
-		return nil
+	return err
+}
+
+// actualCurrent returns the actual current
+func (c *Keba) actualCurrent() (int64, error) {
+	var kr keba.Report2
+	err := c.roundtrip("report 2", 2, &kr)
+	if err != nil {
+		return 0, err
 	}
 
-	return fmt.Errorf("ena unexpected response: %s", resp)
+	return int64(kr.Curruser) / 1000, nil
 }
 
 // MaxCurrent implements the Charger.MaxCurrent interface
 func (c *Keba) MaxCurrent(current int64) error {
+	// ignore result...
 	var resp string
-	err := c.roundtrip(fmt.Sprintf("curr %d", 1000*current), 0, &resp)
-	if err != nil {
-		return err
-	}
+	_ = c.roundtrip(fmt.Sprintf("curr %d", 1000*current), 0, &resp)
 
-	if resp == keba.OK {
-		return nil
+	// ...and verify value
+	res, err := c.actualCurrent()
+	if err == nil && res != current {
+		return fmt.Errorf("curr could not set: %s", resp)
 	}
 
-	return fmt.Errorf("curr unexpected response: %s", resp)
+	return err
 }
 
 // CurrentPower implements the Meter interface
diff --git a/charger/keba/listener.go b/charger/keba/listener.go
index ce11c276..bb2c2bdd 100644
--- a/charger/keba/listener.go
+++ b/charger/keba/listener.go
@@ -2,6 +2,7 @@ package keba
 
 import (
 	"encoding/json"
+	"fmt"
 	"net"
 	"strings"
 	"sync"
@@ -36,37 +37,39 @@ type Listener struct {
 }
 
 // New creates a UDP listener that clients can subscribe to
-func New(log *util.Logger, addr string) *Listener {
+func New(log *util.Logger, addr string) (*Listener, error) {
 	laddr, err := net.ResolveUDPAddr("udp", addr)
 	if err != nil {
-		log.FATAL.Fatal(err)
+		return nil, err
 	}
 
 	conn, err := net.ListenUDP("udp", laddr)
 	if err != nil {
-		log.FATAL.Fatal(err)
+		return nil, err
 	}
 
 	l := &Listener{
-		log:  log,
-		conn: conn,
+		log:     log,
+		conn:    conn,
+		clients: make(map[string]chan<- UDPMsg),
 	}
 
 	go l.listen()
 
-	return l
+	return l, nil
 }
 
 // Subscribe adds a client address and message channel
-func (l *Listener) Subscribe(addr string, c chan<- UDPMsg) {
+func (l *Listener) Subscribe(addr string, c chan<- UDPMsg) error {
 	l.mux.Lock()
 	defer l.mux.Unlock()
 
-	if l.clients == nil {
-		l.clients = make(map[string]chan<- UDPMsg)
+	if _, exists := l.clients[addr]; exists {
+		return fmt.Errorf("duplicate subscription: %s", addr)
 	}
 
 	l.clients[addr] = c
+	return nil
 }
 
 func (l *Listener) listen() {
@@ -75,7 +78,7 @@ func (l *Listener) listen() {
 	for {
 		read, addr, err := l.conn.ReadFrom(b)
 		if err != nil {
-			l.log.WARN.Printf("listener: %v", err)
+			l.log.ERROR.Printf("listener: %v", err)
 			continue
 		}
 
@@ -101,12 +104,24 @@ func (l *Listener) listen() {
 	}
 }
 
+// addrMatches checks if either message sender or serial matched given addr
+func (l *Listener) addrMatches(addr string, msg UDPMsg) bool {
+	switch {
+	case addr == msg.Addr:
+		return true
+	case msg.Report != nil && addr == msg.Report.Serial:
+		return true
+	default:
+		return false
+	}
+}
+
 func (l *Listener) send(msg UDPMsg) {
 	l.mux.Lock()
 	defer l.mux.Unlock()
 
 	for addr, client := range l.clients {
-		if addr == msg.Addr {
+		if l.addrMatches(addr, msg) {
 			select {
 			case client <- msg:
 			default:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 3893c41d..1afee984 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -58,6 +58,7 @@ type LoadPoint struct {
 	SoC struct {
 		AlwaysUpdate bool  `mapstructure:"alwaysUpdate"`
 		Levels       []int `mapstructure:"levels"`
+		Estimate     bool  `mapstructure:"estimate"`
 	}
 	OnDisconnect struct {
 		Mode      api.ChargeMode `mapstructure:"mode"`      // Charge mode to apply when car disconnected
@@ -71,8 +72,9 @@ type LoadPoint struct {
 	chargeTimer api.ChargeTimer
 	chargeRater api.ChargeRater
 
-	chargeMeter api.Meter   // Charger usage meter
-	vehicle     api.Vehicle // Vehicle
+	chargeMeter  api.Meter   // Charger usage meter
+	vehicle      api.Vehicle // Vehicle
+	socEstimator *wrapper.SocEstimator
 
 	// cached state
 	status        api.ChargeStatus // Charger status
@@ -82,7 +84,7 @@ type LoadPoint struct {
 	pvTimer       time.Time        // PV enabled/disable timer
 
 	socCharge      float64       // Vehicle SoC
-	chargedEnergy  float64       // Charged energy while connected
+	chargedEnergy  float64       // Charged energy while connected in Wh
 	chargeDuration time.Duration // Charge duration
 }
 
@@ -111,6 +113,7 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
 	}
 	if lp.VehicleRef != "" {
 		lp.vehicle = cp.Vehicle(lp.VehicleRef)
+		lp.socEstimator = wrapper.NewSocEstimator(log, lp.vehicle, lp.SoC.Estimate)
 	}
 
 	if lp.ChargerRef == "" {
@@ -217,7 +220,7 @@ func (lp *LoadPoint) configureChargerType(charger api.Charger) {
 			lp.chargeMeter = mt
 		} else {
 			mt := &wrapper.ChargeMeter{}
-			_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentHandler)
+			_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentWrappedMeterHandler)
 			_ = lp.bus.Subscribe(evChargeStop, func() {
 				mt.SetPower(0)
 			})
@@ -281,6 +284,9 @@ func (lp *LoadPoint) evVehicleConnectHandler() {
 	lp.connectedTime = lp.clock.Now()
 	lp.publish("connectedDuration", 0)
 
+	// soc estimation reset on car change
+	lp.socEstimator.Reset()
+
 	lp.notify(evVehicleConnect)
 }
 
@@ -304,9 +310,16 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 }
 
 // evChargeCurrentHandler updates the dummy charge meter's charge power. This simplifies the main flow
-// where the charge meter can always be treated as present. It assumes that the charge meter cannot consume
-// more than total household consumption. If physical charge meter is present this handler is not used.
 func (lp *LoadPoint) evChargeCurrentHandler(current int64) {
+	lp.publish("chargeCurrent", current)
+}
+
+// evChargeCurrentWrappedMeterHandler updates the dummy charge meter's charge power.
+// This simplifies the main flow where the charge meter can always be treated as present.
+// It assumes that the charge meter cannot consume more than total household consumption.
+// If physical charge meter is present this handler is not used.
+// The actual value is published by the evChargeCurrentHandler
+func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current int64) {
 	power := float64(current*lp.Phases) * Voltage
 
 	if !lp.handler.Enabled() || lp.status != api.StatusC {
@@ -324,9 +337,6 @@ func (lp *LoadPoint) evChargeCurrentHandler(current int64) {
 
 	// handler only called if charge meter was replaced by dummy
 	lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
-
-	// expose for UI
-	lp.publish("chargeCurrent", current)
 }
 
 // Name returns the human-readable loadpoint title
@@ -345,6 +355,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	_ = lp.bus.Subscribe(evChargeStop, lp.evChargeStopHandler)
 	_ = lp.bus.Subscribe(evVehicleConnect, lp.evVehicleConnectHandler)
 	_ = lp.bus.Subscribe(evVehicleDisconnect, lp.evVehicleDisconnectHandler)
+	_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentHandler)
 
 	// publish initial values
 	lp.Lock()
@@ -416,20 +427,21 @@ func (lp *LoadPoint) detectPhases() {
 		return
 	}
 
-	lp.log.TRACE.Printf("charge currents: %vA", []float64{i1, i2, i3})
-	lp.publish("chargeCurrents", []float64{i1, i2, i3})
+	currents := []float64{i1, i2, i3}
+	lp.log.TRACE.Printf("charge currents: %vA", currents)
+	lp.publish("chargeCurrents", currents)
 
 	if lp.charging {
 		var phases int64
-		for _, i := range []float64{i1, i2, i3} {
+		for _, i := range currents {
 			if i >= minActiveCurrent {
 				phases++
 			}
 		}
 
 		if phases > 0 {
-			lp.Phases = min(phases, lp.Phases)
-			lp.log.DEBUG.Printf("detected phases: %d (%v)", lp.Phases, []float64{i1, i2, i3})
+			lp.Phases = phases
+			lp.log.DEBUG.Printf("detected phases: %dp %vA", lp.Phases, currents)
 
 			lp.publish("activePhases", lp.Phases)
 		}
@@ -564,43 +576,28 @@ func (lp *LoadPoint) publishChargeProgress() {
 	lp.publish("chargeDuration", lp.chargeDuration)
 }
 
-// remainingChargeDuration returns the remaining charge time
-func (lp *LoadPoint) remainingChargeDuration(chargePercent float64) time.Duration {
-	if !lp.charging {
-		return -1
-	}
-
-	if lp.chargePower > 0 && lp.vehicle != nil {
-		chargePercent = chargePercent / 100.0
-		targetPercent := float64(lp.TargetSoC) / 100
-
-		if chargePercent >= targetPercent {
-			return 0
-		}
-
-		whTotal := float64(lp.vehicle.Capacity()) * 1e3
-		whRemaining := (targetPercent - chargePercent) * whTotal
-		return time.Duration(float64(time.Hour) * whRemaining / lp.chargePower).Round(time.Second)
-	}
-
-	return -1
-}
-
 // publish state of charge and remaining charge duration
 func (lp *LoadPoint) publishSoC() {
-	if lp.vehicle == nil {
+	if lp.socEstimator == nil {
 		return
 	}
 
 	if lp.SoC.AlwaysUpdate || lp.connected() {
-		f, err := lp.vehicle.ChargeState()
+		f, err := lp.socEstimator.SoC(lp.chargedEnergy)
 		if err == nil {
 			lp.socCharge = f
 			lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.socCharge)
 			lp.publish("socCharge", lp.socCharge)
-			lp.publish("chargeEstimate", lp.remainingChargeDuration(f))
+
+			chargeEstimate := time.Duration(-1)
+			if lp.charging {
+				chargeEstimate = lp.socEstimator.RemainingChargeDuration(lp.chargePower, lp.TargetSoC)
+			}
+			lp.publish("chargeEstimate", chargeEstimate)
+
 			return
 		}
+
 		lp.log.ERROR.Printf("vehicle error: %v", err)
 	}
 
diff --git a/core/wrapper/socestimator.go b/core/wrapper/socestimator.go
new file mode 100644
index 00000000..576cc095
--- /dev/null
+++ b/core/wrapper/socestimator.go
@@ -0,0 +1,91 @@
+package wrapper
+
+import (
+	"math"
+	"time"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/util"
+)
+
+// SocEstimator provides vehicle soc and charge duration
+// Vehicle SoC can be estimated to provide more granularity
+type SocEstimator struct {
+	log      *util.Logger
+	vehicle  api.Vehicle
+	estimate bool
+
+	capacity          float64 // vehicle capacity in Wh cached to simplify testing
+	socCharge         float64 // estimated vehicle SoC
+	prevSoC           float64 // previous vehicle SoC in %
+	prevChargedEnergy float64 // previous charged energy in Wh
+	energyPerSocStep  float64 // Energy per SoC percent in Wh
+}
+
+// NewSocEstimator creates new estimator
+func NewSocEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *SocEstimator {
+	s := &SocEstimator{
+		log:      log,
+		vehicle:  vehicle,
+		estimate: estimate,
+	}
+
+	s.Reset()
+
+	return s
+}
+
+// Reset resets the estimation process to default values
+func (s *SocEstimator) Reset() {
+	s.prevSoC = 0
+	s.prevChargedEnergy = 0
+	s.capacity = float64(s.vehicle.Capacity()) * 1e3 // cache to simplify debugging
+	s.energyPerSocStep = s.capacity / 100
+}
+
+// RemainingChargeDuration returns the remaining duration estimate based on SoC, target and charge power
+func (s *SocEstimator) RemainingChargeDuration(chargePower float64, targetSoC int) time.Duration {
+	if chargePower > 0 {
+		percentRemaining := float64(targetSoC) - s.socCharge
+		if percentRemaining <= 0 {
+			return 0
+		}
+
+		whRemaining := percentRemaining / 100 * s.capacity
+		return time.Duration(float64(time.Hour) * whRemaining / chargePower).Round(time.Second)
+	}
+
+	return -1
+}
+
+// SoC implements Vehicle.ChargeState with addition of given charged energy
+func (s *SocEstimator) SoC(chargedEnergy float64) (float64, error) {
+	f, err := s.vehicle.ChargeState()
+	if err != nil {
+		return s.socCharge, err
+	}
+
+	s.socCharge = f
+
+	if s.estimate {
+		socDelta := s.socCharge - s.prevSoC
+		energyDelta := math.Max(chargedEnergy, 0) - s.prevChargedEnergy
+
+		if socDelta != 0 || energyDelta < 0 { // soc value change or unexpected energy reset
+			// calculate gradient, wh per soc %
+			if socDelta > 1 && energyDelta > 0 && s.prevSoC > 0 {
+				s.energyPerSocStep = energyDelta / socDelta
+				s.log.TRACE.Printf("soc gradient updated: energyPerSocStep: %0.0fWh, virtualBatCap: %0.1fkWh", s.energyPerSocStep, s.energyPerSocStep*100/1e3)
+			}
+
+			// sample charged energy at soc change, reset energy delta
+			s.prevChargedEnergy = math.Max(chargedEnergy, 0)
+			s.prevSoC = s.socCharge
+		} else {
+			s.socCharge = math.Min(f + energyDelta / s.energyPerSocStep, 100)
+			s.log.TRACE.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, f)
+		}
+	}
+
+	return s.socCharge, nil
+}
diff --git a/bin/docker-btmgmt b/docker/bin/docker-btmgmt
similarity index 100%
rename from bin/docker-btmgmt
rename to docker/bin/docker-btmgmt
diff --git a/bin/entrypoint.sh b/docker/bin/entrypoint.sh
similarity index 100%
rename from bin/entrypoint.sh
rename to docker/bin/entrypoint.sh
diff --git a/tmpl.Dockerfile b/docker/tmpl.Dockerfile
similarity index 95%
rename from tmpl.Dockerfile
rename to docker/tmpl.Dockerfile
index 85e3222e..53ef1d07 100644
--- a/tmpl.Dockerfile
+++ b/docker/tmpl.Dockerfile
@@ -24,12 +24,14 @@ RUN GOARCH={{ .GoARCH }} GOARM={{ .GoARM }} make build
 #############################
 FROM {{ .RuntimeImage }}
 
+ENV TZ=Europe/Berlin
+
 # Import from builder
 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 COPY --from=builder /build/evcc /usr/local/bin/evcc
 
-COPY bin/* /evcc/
+COPY docker/bin/* /evcc/
 
 EXPOSE 7070
 
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index aa891c7b..3e2de81e 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -132,6 +132,7 @@ loadpoints:
   targetSoC: 100 # charge to 100%
   soc:
     alwaysUpdate: false # set true to update vehicle soc even when disconnected
+    estimate: false # set true to interpolate between api updates
     levels: # target soc levels for UI
     - 30
     - 50
diff --git a/go.mod b/go.mod
index a800ff49..45d9b37a 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,7 @@ require (
 	github.com/gregdel/pushover v0.0.0-20200416074932-c8ad547caed4
 	github.com/grid-x/modbus v0.0.0-20200704184817-73c05928caa1
 	github.com/hashicorp/go-version v1.2.1
-	github.com/influxdata/influxdb-client-go v1.4.0
+	github.com/influxdata/influxdb-client-go/v2 v2.0.1
 	github.com/itchyny/gojq v0.11.0
 	github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a
 	github.com/jsgoecke/tesla v0.0.0-20200530171421-e02ebd220e5a
@@ -35,7 +35,6 @@ require (
 	github.com/spf13/viper v1.7.1
 	github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
 	github.com/volkszaehler/mbmd v0.0.0-20200804054214-122815825570
-	github.com/yuin/goldmark v1.1.32
 	golang.org/x/net v0.0.0-20200707034311-ab3426394381
 	golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6
 	gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c
diff --git a/go.sum b/go.sum
index 550c8564..4f262b05 100644
--- a/go.sum
+++ b/go.sum
@@ -177,6 +177,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/influxdata/influxdb-client-go v1.4.0 h1:+KavOkwhLClHFfYcJMHHnTL5CZQhXJzOm5IKHI9BqJk=
 github.com/influxdata/influxdb-client-go v1.4.0/go.mod h1:S+oZsPivqbcP1S9ur+T+QqXvrYS3NCZeMQtBoH4D1dw=
+github.com/influxdata/influxdb-client-go/v2 v2.0.1 h1:vRla3taM+zkziP1NUGfN6Y6zJ9ZSSMg0fs/JhCGyX1s=
+github.com/influxdata/influxdb-client-go/v2 v2.0.1/go.mod h1:eyFPc0lhFnNSpyCDb0ZkrB3Hbtqvn1K1JZmjo2BXqeo=
 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d h1:/WZQPMZNsjZ7IlCpsLGdQBINg5bxKQ1K1sh6awxLtkA=
 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
 github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
diff --git a/server/influxdb.go b/server/influxdb.go
index 24ca1dfb..dc5505b0 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -7,7 +7,7 @@ import (
 
 	"github.com/andig/evcc/core"
 	"github.com/andig/evcc/util"
-	influxdb2 "github.com/influxdata/influxdb-client-go"
+	influxdb2 "github.com/influxdata/influxdb-client-go/v2"
 )
 
 // InfluxConfig is the influx db configuration
@@ -69,7 +69,7 @@ func (m *Influx) supportedType(p util.Param) bool {
 
 // Run Influx publisher
 func (m *Influx) Run(loadPoints []*core.LoadPoint, in <-chan util.Param) {
-	writer := m.client.WriteApi(m.org, m.database)
+	writer := m.client.WriteAPI(m.org, m.database)
 
 	// log errors
 	go func() {
diff --git a/server/updater/updater.go b/server/updater/updater.go
index 172e4d25..7a087b84 100644
--- a/server/updater/updater.go
+++ b/server/updater/updater.go
@@ -12,9 +12,6 @@ import (
 	"github.com/google/go-github/v32/github"
 	"github.com/hashicorp/go-version"
 	latest "github.com/tcnksm/go-latest"
-	"github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/extension"
-	"github.com/yuin/goldmark/renderer/html"
 )
 
 var instance *updater
@@ -67,18 +64,18 @@ func (u *updater) checkVersion() {
 			Val: tag,
 		}
 
-		u.fetchReleaseNotes(res.Current)
+		u.fetchReleaseNotes()
 	} else {
 		u.log.ERROR.Println("version check failed:", err)
 	}
 }
 
 // fetchReleaseNotes retrieves release notes up to semver and sends to client
-func (u *updater) fetchReleaseNotes(to string) {
+func (u *updater) fetchReleaseNotes() {
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer cancel()
 
-	if notes, err := releaseNotes(ctx, tag2semver(server.Version), to); err == nil {
+	if notes, err := releaseNotes(ctx, tag2semver(server.Version)); err == nil {
 		u.cache <- util.Param{
 			Key: "releaseNotes",
 			Val: notes,
@@ -89,14 +86,11 @@ func (u *updater) fetchReleaseNotes(to string) {
 }
 
 // releaseNotes returns github release notes for the (from,to] semver interval
-func releaseNotes(ctx context.Context, from, to string) (rendered string, err error) {
-	var fromVersion, toVersion *version.Version
+func releaseNotes(ctx context.Context, from string) (rendered string, err error) {
+	var fromVersion *version.Version
 	if fromVersion, err = version.NewVersion(from); err != nil {
 		return
 	}
-	if toVersion, err = version.NewVersion(to); err != nil {
-		return
-	}
 
 	client := github.NewClient(nil)
 	releases, _, err := client.Repositories.ListReleases(ctx, server.Owner, server.Repository, nil)
@@ -104,12 +98,7 @@ func releaseNotes(ctx context.Context, from, to string) (rendered string, err er
 		return
 	}
 
-	notes := bytes.NewBuffer([]byte{})
-	gm := goldmark.New(
-		goldmark.WithExtensions(extension.GFM),
-		goldmark.WithRendererOptions(html.WithHardWraps()),
-	)
-
+	history := bytes.NewBuffer([]byte{})
 	for _, rel := range releases {
 		tag := *rel.TagName
 
@@ -118,18 +107,23 @@ func releaseNotes(ctx context.Context, from, to string) (rendered string, err er
 			return
 		}
 
-		if ver.GreaterThan(fromVersion) && ver.LessThanOrEqual(toVersion) {
-			body := strings.TrimSpace(rel.GetBody())
-			if body == "" {
-				continue
-			}
+		if ver.LessThanOrEqual(fromVersion) {
+			continue
+		}
 
-			notes.WriteString(fmt.Sprintf("<h1>%s</h1>\n", tag))
-			if err = gm.Convert([]byte(body), notes); err != nil {
+		if notes := strings.TrimSpace(rel.GetBody()); notes != "" {
+			var md string
+			if md, _, err = client.Markdown(context.Background(), notes, &github.MarkdownOptions{
+				Mode:    "gfm",
+				Context: fmt.Sprintf("%s/%s", server.Owner, server.Repository),
+			}); err != nil {
 				return
 			}
+
+			history.WriteString(fmt.Sprintf("<h1>%s</h1>\n", tag))
+			history.WriteString(md)
 		}
 	}
 
-	return notes.String(), nil
+	return history.String(), nil
 }


Test patch:
diff --git a/charger/keba_test.go b/charger/keba_test.go
index 9816e5e4..0588968d 100644
--- a/charger/keba_test.go
+++ b/charger/keba_test.go
@@ -8,7 +8,7 @@ import (
 
 func TestKeba(t *testing.T) {
 	var wb api.Charger
-	wb, err := NewKeba("foo", RFID{}, 0)
+	wb, err := NewKeba("foo", "bar", RFID{}, 0)
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 371a3a00..72cd1890 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/core/wrapper"
 	"github.com/andig/evcc/mock"
 	"github.com/andig/evcc/push"
 	"github.com/andig/evcc/util"
@@ -366,32 +367,16 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
 	ctrl.Finish()
 }
 
-func TestRemainingChargeDuration(t *testing.T) {
-	lp := NewLoadPoint(util.NewLogger("foo"))
-
-	ctrl := gomock.NewController(t)
-	vehicle := mock.NewMockVehicle(ctrl)
-
-	lp.vehicle = vehicle
-	lp.charging = true
-
-	soc := 20.0
-	lp.TargetSoC = 80
-	lp.chargePower = 1000
-
-	vehicle.EXPECT().Capacity().Return(int64(10))
-
-	if remaining := lp.remainingChargeDuration(soc); remaining != 6*time.Hour {
-		t.Error("wrong remaining charge duration")
-	}
-}
-
 func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 	clock := clock.NewMock()
 	ctrl := gomock.NewController(t)
 	handler := mock.NewMockHandler(ctrl)
 	vehicle := mock.NewMockVehicle(ctrl)
 
+	// wrap vehicle with estimator
+	vehicle.EXPECT().Capacity().Return(int64(10))
+	socEstimator := wrapper.NewSocEstimator(util.NewLogger("foo"), vehicle, false)
+
 	lp := &LoadPoint{
 		log:         util.NewLogger("foo"),
 		bus:         evbus.New(),
@@ -403,10 +388,10 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 			MinCurrent: lpMinCurrent,
 			MaxCurrent: lpMaxCurrent,
 		},
-		handler:   handler,
-		vehicle:   vehicle,
-		status:    api.StatusC,
-		TargetSoC: 90,
+		handler:      handler,
+		socEstimator: socEstimator, // instead of vehicle: vehicle,
+		status:       api.StatusC,
+		TargetSoC:    90,
 	}
 
 	handler.EXPECT().Prepare().Return()
diff --git a/core/wrapper/socestimator_test.go b/core/wrapper/socestimator_test.go
new file mode 100644
index 00000000..c402358e
--- /dev/null
+++ b/core/wrapper/socestimator_test.go
@@ -0,0 +1,82 @@
+package wrapper
+
+import (
+	"testing"
+	"time"
+
+	"github.com/andig/evcc/mock"
+	"github.com/andig/evcc/util"
+	"github.com/golang/mock/gomock"
+)
+
+func TestRemainingChargeDuration(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	vehicle := mock.NewMockVehicle(ctrl)
+	vehicle.EXPECT().Capacity().Return(int64(10))
+
+	ce := NewSocEstimator(util.NewLogger("foo"), vehicle, false)
+	ce.socCharge = 20.0
+
+	chargePower := 1000.0
+	targetSoC := 80
+
+	if remaining := ce.RemainingChargeDuration(chargePower, targetSoC); remaining != 6*time.Hour {
+		t.Error("wrong remaining charge duration")
+	}
+}
+
+func TestSoCEstimation(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	vehicle := mock.NewMockVehicle(ctrl)
+	vehicle.EXPECT().Capacity().Return(int64(10))
+
+	ce := NewSocEstimator(util.NewLogger("foo"), vehicle, true)
+	ce.socCharge = 20.0
+
+	tc := []struct {
+		chargedEnergy float64
+		vehicleSoC    float64
+		estimatedSoC  float64
+	}{
+		{10, 20.0, 20.0},
+		{0, 20.0, 20.0},
+		{123, 20.0, 21.23},
+		{1000, 20.0, 30.0},
+		{1100, 31.0, 31.0},
+		{1200, 32.0, 32.0},
+		{1900, 39.0, 39.0},
+		{2000, 40.0, 40.0},
+		{4000, 50.0, 50.0},
+		{6000, 60.0, 60.0},
+		{6500, 65.0, 65.0},
+		{7000, 65.0, 70.0},
+		{7100, 71.0, 71.0},
+		{7300, 72.0, 72.0},
+		{7400, 73.0, 73.0},
+		{7700, 75.0, 75.0},
+		{8200, 80.0, 80.0},
+		{0, 25.0, 25.0},
+		{2500, 25.0, 50.0},
+		{-10000, 50.0, 50.0},
+		{4990, 50.0, 99.9},
+		{5000, 50.0, 100.0},
+		{5001, 50.0, 100.0},
+		{0, 0.0, 0.0},
+		{1000, 0.0, 10.0},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+		vehicle.EXPECT().ChargeState().Return(tc.vehicleSoC, nil)
+
+		soc, err := ce.SoC(tc.chargedEnergy)
+		if err != nil {
+			t.Error(err)
+		}
+		t.Logf("%+v", ce)
+
+		if tc.estimatedSoC != soc {
+			t.Errorf("expected: %g, got: %g", tc.estimatedSoC, soc)
+		}
+	}
+}