Repository: andig/evcc
Pull request #889 (2021-04-18 09:20:36)

InfluxDB: add vehicle tag


Patch:
diff --git a/Makefile b/Makefile
index 244299c5..7999e58a 100644
--- a/Makefile
+++ b/Makefile
@@ -109,5 +109,6 @@ server:
 	go build -o soc-server $(BUILD_TAGS) $(BUILD_ARGS) github.com/andig/evcc/soc/server
 
 publish-server:
+	GOOS=linux GOARCH=amd64 go build -o soc-server $(BUILD_TAGS) $(BUILD_ARGS) github.com/andig/evcc/soc/server
 	docker build -f soc/Dockerfile --platform linux/amd64 -t andig/evcc-cloud .
 	docker push andig/evcc-cloud
diff --git a/core/loadpoint.go b/core/loadpoint.go
index cf0e19c4..9d7def5e 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -678,7 +678,7 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
 	deltaCurrent := powerToCurrent(-sitePower, lp.Phases)
 	targetCurrent := math.Max(math.Min(effectiveCurrent+deltaCurrent, float64(lp.MaxCurrent)), 0)
 
-	lp.log.DEBUG.Printf("max charge current: %.2gA = %.2gA + %.2gA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, lp.Phases)
+	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) {
@@ -713,7 +713,7 @@ func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64) float6
 
 	if mode == api.ModePV && !lp.enabled {
 		// kick off enable sequence
-		if targetCurrent >= float64(lp.MinCurrent) ||
+		if (lp.Enable.Threshold == 0 && targetCurrent >= float64(lp.MinCurrent)) ||
 			(lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
 			lp.log.DEBUG.Printf("site power %.0fW < enable threshold %.0fW", sitePower, lp.Enable.Threshold)
 
diff --git a/detect/definitions.go b/detect/definitions.go
index 8ac3f29f..8bfdf1c7 100644
--- a/detect/definitions.go
+++ b/detect/definitions.go
@@ -37,6 +37,7 @@ const (
 	taskMeter        = "meter"
 	taskFronius      = "fronius"
 	taskTasmota      = "tasmota"
+	taskTPLink       = "tplink"
 )
 
 func init() {
@@ -241,6 +242,15 @@ func init() {
 		},
 	})
 
+	taskList.Add(Task{
+		ID:      taskTPLink,
+		Type:    "tcp",
+		Depends: TaskPing,
+		Config: map[string]interface{}{
+			"port": 9999, // TP-Link Smart Home Protocol standard port
+		},
+	})
+
 	// taskList.Add(Task{
 	// 	ID:      "volkszähler",
 	// 	Type:    "http",
diff --git a/internal/charger/tplink.go b/internal/charger/tplink.go
new file mode 100644
index 00000000..25fec939
--- /dev/null
+++ b/internal/charger/tplink.go
@@ -0,0 +1,191 @@
+package charger
+
+import (
+	"bytes"
+	"encoding/binary"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net"
+	"strings"
+	"time"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/internal/charger/tplink"
+	"github.com/andig/evcc/util"
+)
+
+// TPLink charger implementation
+type TPLink struct {
+	uri          string
+	standbypower float64
+}
+
+func init() {
+	registry.Add("tplink", NewTPLinkFromConfig)
+}
+
+// NewTPLinkFromConfig creates a TP-Link charger from generic config
+func NewTPLinkFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := struct {
+		URI          string
+		StandbyPower float64
+	}{}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	if cc.URI == "" {
+		return nil, errors.New("missing uri")
+	}
+
+	return NewTPLink(cc.URI, cc.StandbyPower)
+}
+
+// NewTPLink creates TP-Link charger
+func NewTPLink(uri string, standbypower float64) (*TPLink, error) {
+	c := &TPLink{
+		uri:          net.JoinHostPort(uri, "9999"),
+		standbypower: standbypower,
+	}
+	return c, nil
+}
+
+// Enabled implements the Charger.Enabled interface
+func (c *TPLink) Enabled() (bool, error) {
+	sysResp, err := c.execCmd(`{"system":{"get_sysinfo":null}}`)
+	if err != nil {
+		return false, err
+	}
+
+	var systemResponse tplink.SystemResponse
+	if err := json.Unmarshal(sysResp, &systemResponse); err != nil {
+		return false, err
+	}
+
+	if err := systemResponse.System.GetSysinfo.ErrCode; err != 0 {
+		return false, fmt.Errorf("get_sysinfo error %d", err)
+	}
+
+	if !strings.Contains(systemResponse.System.GetSysinfo.Feature, "ENE") {
+		return false, errors.New(systemResponse.System.GetSysinfo.Model + " not supported, energy meter feature missing")
+	}
+
+	return int(1) == systemResponse.System.GetSysinfo.RelayState, err
+}
+
+// Enable implements the Charger.Enable interface
+func (c *TPLink) Enable(enable bool) error {
+	cmd := `{"system":{"set_relay_state":{"state":0}}}`
+	if enable {
+		cmd = `{"system":{"set_relay_state":{"state":1}}}`
+	}
+
+	// Execute TP-Link set_relay_state command
+	sysResp, err := c.execCmd(cmd)
+	if err != nil {
+		return err
+	}
+
+	var systemResponse tplink.SystemResponse
+	if err := json.Unmarshal(sysResp, &systemResponse); err != nil {
+		return err
+	}
+
+	if err := systemResponse.System.SetRelayState.ErrCode; err != 0 {
+		return fmt.Errorf("set_relay_state error %d", err)
+	}
+
+	return nil
+}
+
+// MaxCurrent implements the Charger.MaxCurrent interface
+func (c *TPLink) MaxCurrent(current int64) error {
+	return nil
+}
+
+// Status implements the Charger.Status interface
+func (c *TPLink) Status() (api.ChargeStatus, error) {
+	power, err := c.CurrentPower()
+
+	switch {
+	case power > 0:
+		return api.StatusC, err
+	default:
+		return api.StatusB, err
+	}
+}
+
+var _ api.Meter = (*TPLink)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (c *TPLink) CurrentPower() (float64, error) {
+	emeResp, err := c.execCmd(`{"emeter":{"get_realtime":null}}`)
+	if err != nil {
+		return 0, err
+	}
+
+	var emeterResponse tplink.EmeterResponse
+	if err := json.Unmarshal(emeResp, &emeterResponse); err != nil {
+		return 0, err
+	}
+	if err := emeterResponse.Emeter.GetRealtime.ErrCode; err != 0 {
+		return 0, fmt.Errorf("get_realtime error %d", err)
+	}
+
+	power := emeterResponse.Emeter.GetRealtime.PowerMw / 1000
+	if power == 0 {
+		power = emeterResponse.Emeter.GetRealtime.Power
+	}
+
+	// ignore standby power
+	if power < c.standbypower {
+		power = 0
+	}
+
+	return power, err
+}
+
+// execCmd executes an TP-Link Smart Home Protocol command and provides the response
+func (c *TPLink) execCmd(cmd string) ([]byte, error) {
+	// encode command message
+	buf := bytes.NewBuffer([]byte{0, 0, 0, 0})
+	var ekey byte = 171 // initialization vector
+	for i := 0; i < len(cmd); i++ {
+		ekey = ekey ^ cmd[i]
+		_ = buf.WriteByte(ekey)
+	}
+
+	// write 4 bytes to start of buffer with command length
+	binary.BigEndian.PutUint32(buf.Bytes(), uint32(buf.Len()-4))
+
+	// open connection via TP-Link Smart Home Protocol
+	conn, err := net.DialTimeout("tcp", c.uri, 5*time.Second)
+	if err != nil {
+		return nil, err
+	}
+	defer conn.Close()
+
+	// send command
+	if _, err = buf.WriteTo(conn); err != nil {
+		return nil, err
+	}
+
+	// read response
+	resp := make([]byte, 2048)
+	n, err := conn.Read(resp)
+	if err != nil {
+		return nil, err
+	}
+
+	// decode response message
+	var dkey byte = 171 // initialization vector
+	for i := 4; i < n; i++ {
+		dec := dkey ^ resp[i]
+		dkey = resp[i]
+		_ = buf.WriteByte(dec)
+	}
+
+	return buf.Bytes(), nil
+}
diff --git a/internal/charger/tplink/types.go b/internal/charger/tplink/types.go
new file mode 100644
index 00000000..8682d160
--- /dev/null
+++ b/internal/charger/tplink/types.go
@@ -0,0 +1,41 @@
+package tplink
+
+// TP-Link smart power plug/outlet responses
+// https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/#Portscan
+
+// SystemResponse is the TP-Link plug/outlet api system response
+type SystemResponse struct {
+	System struct {
+		SetRelayState struct {
+			ErrCode int `json:"err_code,omitempty"`
+		} `json:"set_relay_state"`
+		GetSysinfo struct {
+			ErrCode    int    `json:"err_code,omitempty"`
+			SwVer      string `json:"sw_ver,omitempty"`
+			Model      string `json:"model,omitempty"`
+			Alias      string `json:"alias,omitempty"`
+			DevName    string `json:"dev_name,omitempty"`
+			RelayState int    `json:"relay_state,omitempty"`
+			Feature    string `json:"feature,omitempty"`
+		} `json:"get_sysinfo"`
+	} `json:"system"`
+}
+
+// EmeterResponse is the TP-Link plug/outlet api emeter response
+type EmeterResponse struct {
+	Emeter struct {
+		GetRealtime struct {
+			// 1st plug generation E-Meter Response
+			Current float64 `json:"current,omitempty"`
+			Voltage float64 `json:"voltage,omitempty"`
+			Power   float64 `json:"power,omitempty"`
+			Total   float64 `json:"total,omitempty"`
+			// 2nd plug generation E-Meter Response
+			CurrentMa float64 `json:"current_ma,omitempty"`
+			VoltageMv float64 `json:"voltage_mv,omitempty"`
+			PowerMw   float64 `json:"power_mw,omitempty"`
+			TotalWh   float64 `json:"total_wh,omitempty"`
+			ErrCode   int     `json:"err_code,omitempty"`
+		} `json:"get_realtime"`
+	} `json:"emeter"`
+}
diff --git a/internal/vehicle/bluelink/api.go b/internal/vehicle/bluelink/api.go
new file mode 100644
index 00000000..fb5e8f28
--- /dev/null
+++ b/internal/vehicle/bluelink/api.go
@@ -0,0 +1,166 @@
+package bluelink
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/provider"
+	"github.com/andig/evcc/util"
+	"github.com/andig/evcc/util/request"
+)
+
+const resOK = "S" // auth fail: F
+
+const (
+	VehiclesURL = "/api/v1/spa/vehicles"
+	StatusURL   = "/api/v1/spa/vehicles/%s/status"
+)
+
+// ErrAuthFail indicates authorization failure
+var ErrAuthFail = errors.New("authorization failed")
+
+// API implements the Kia/Hyundai bluelink api.
+// Based on https://github.com/Hacksore/bluelinky.
+type API struct {
+	*request.Helper
+	log      *util.Logger
+	identity *Identity
+	apiG     func() (interface{}, error)
+	Vehicle  Vehicle
+}
+
+// New creates a new BlueLink API
+func NewAPI(log *util.Logger, identity *Identity, cache time.Duration) *API {
+	v := &API{
+		log:      log,
+		identity: identity,
+		Helper:   request.NewHelper(log),
+	}
+
+	// api is unbelievably slow when retrieving status
+	v.Helper.Client.Timeout = 120 * time.Second
+
+	v.apiG = provider.NewCached(v.statusAPI, cache).InterfaceGetter()
+
+	return v
+}
+
+type VehiclesResponse struct {
+	RetCode string
+	ResMsg  struct {
+		Vehicles []Vehicle
+	}
+}
+
+type Vehicle struct {
+	Vin, VehicleName, VehicleID string
+}
+
+func (v *API) Vehicles() ([]Vehicle, error) {
+	req, err := v.identity.Request(http.MethodGet, VehiclesURL)
+
+	var resp VehiclesResponse
+	if err == nil {
+		err = v.DoJSON(req, &resp)
+	}
+
+	return resp.ResMsg.Vehicles, err
+}
+
+type StatusResponse struct {
+	timestamp time.Time // add missing timestamp
+	RetCode   string
+	ResMsg    struct {
+		EvStatus struct {
+			BatteryStatus float64
+			RemainTime2   struct {
+				Atc struct {
+					Value, Unit int
+				}
+			}
+			DrvDistance []DrivingDistance
+		}
+		Vehicles []Vehicle
+	}
+}
+
+type DrivingDistance struct {
+	RangeByFuel struct {
+		EvModeRange struct {
+			Value int
+		}
+	}
+}
+
+func (v *API) getStatus() (StatusResponse, error) {
+	var resp StatusResponse
+
+	req, err := v.identity.Request(http.MethodGet, fmt.Sprintf(StatusURL, v.Vehicle.VehicleID))
+	if err == nil {
+		if err = v.DoJSON(req, &resp); err == nil && resp.RetCode != resOK {
+			err = fmt.Errorf("unexpected response: %s", resp.RetCode)
+		}
+	}
+
+	return resp, err
+}
+
+// status retrieves the bluelink status response
+func (v *API) statusAPI() (interface{}, error) {
+	res, err := v.getStatus()
+	res.timestamp = time.Now() // add local timestamp to cache for FinishTime
+
+	return res, err
+}
+
+var _ api.Battery = (*API)(nil)
+
+// SoC implements the api.Vehicle interface
+func (v *API) SoC() (float64, error) {
+	res, err := v.apiG()
+
+	if res, ok := res.(StatusResponse); err == nil && ok {
+		return float64(res.ResMsg.EvStatus.BatteryStatus), nil
+	}
+
+	return 0, err
+}
+
+var _ api.VehicleFinishTimer = (*API)(nil)
+
+// FinishTime implements the api.VehicleFinishTimer interface
+func (v *API) FinishTime() (time.Time, error) {
+	res, err := v.apiG()
+
+	if res, ok := res.(StatusResponse); err == nil && ok {
+		remaining := res.ResMsg.EvStatus.RemainTime2.Atc.Value
+
+		if remaining == 0 {
+			return time.Time{}, api.ErrNotAvailable
+		}
+
+		return res.timestamp.Add(time.Duration(remaining) * time.Minute), nil
+	}
+
+	return time.Time{}, err
+}
+
+var _ api.VehicleRange = (*API)(nil)
+
+// Range implements the api.VehicleRange interface
+func (v *API) Range() (int64, error) {
+	res, err := v.apiG()
+
+	if res, ok := res.(StatusResponse); err == nil && ok {
+		if dist := res.ResMsg.EvStatus.DrvDistance; len(dist) == 1 {
+			return int64(dist[0].RangeByFuel.EvModeRange.Value), nil
+		}
+
+		return 0, api.ErrNotAvailable
+	}
+
+	return 0, err
+}
diff --git a/internal/vehicle/bluelink/bluelink.go b/internal/vehicle/bluelink/bluelink.go
deleted file mode 100644
index c2a4a6bc..00000000
--- a/internal/vehicle/bluelink/bluelink.go
+++ /dev/null
@@ -1,402 +0,0 @@
-package bluelink
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"net/http/cookiejar"
-	"net/url"
-	"strings"
-	"time"
-
-	"github.com/andig/evcc/api"
-	"github.com/andig/evcc/provider"
-	"github.com/andig/evcc/util"
-	"github.com/andig/evcc/util/request"
-	"github.com/google/uuid"
-	"github.com/imdario/mergo"
-	"golang.org/x/net/publicsuffix"
-)
-
-const (
-	resOK       = "S"
-	resAuthFail = "F"
-)
-
-var (
-	errAuthFail = errors.New("authorization failed")
-
-	defaults = Config{
-		DeviceID:    "/api/v1/spa/notifications/register",
-		Lang:        "/api/v1/user/language",
-		Login:       "/api/v1/user/signin",
-		AccessToken: "/api/v1/user/oauth2/token",
-		Vehicles:    "/api/v1/spa/vehicles",
-		Status:      "/api/v1/spa/vehicles/%s/status",
-	}
-)
-
-// Config is the bluelink API configuration
-type Config struct {
-	URI               string
-	TokenAuth         string
-	CCSPServiceID     string
-	CCSPApplicationID string
-	DeviceID          string
-	Lang              string
-	Login             string
-	AccessToken       string
-	Vehicles          string
-	Status            string
-}
-
-// API implements the Kia/Hyundai bluelink api.
-// Based on https://github.com/Hacksore/bluelinky.
-type API struct {
-	*request.Helper
-	log      *util.Logger
-	user     string
-	password string
-	apiG     func() (interface{}, error)
-	config   Config
-	auth     Auth
-}
-
-// Auth bundles miscellaneous authorization data
-type Auth struct {
-	accToken  string
-	deviceID  string
-	vehicleID string
-}
-
-type response struct {
-	timestamp time.Time // add missing timestamp
-	RetCode   string
-	ResMsg    struct {
-		DeviceID string
-		EvStatus struct {
-			BatteryStatus float64
-			RemainTime2   struct {
-				Atc struct {
-					Value, Unit int
-				}
-			}
-			DrvDistance []drvDistance
-		}
-		Vehicles []struct {
-			VehicleID string
-		}
-	}
-}
-
-type drvDistance struct {
-	RangeByFuel struct {
-		EvModeRange struct {
-			Value int
-		}
-	}
-}
-
-// New creates a new BlueLink API
-func New(log *util.Logger, user, password string, cache time.Duration, config Config) (*API, error) {
-	if err := mergo.Merge(&config, defaults); err != nil {
-		return nil, err
-	}
-
-	v := &API{
-		log:      log,
-		Helper:   request.NewHelper(log),
-		config:   config,
-		user:     user,
-		password: password,
-	}
-
-	// api is unbelievably slow when retrieving status
-	v.Helper.Client.Timeout = 120 * time.Second
-
-	v.apiG = provider.NewCached(v.statusAPI, cache).InterfaceGetter()
-
-	return v, nil
-}
-
-// Credits to https://openwb.de/forum/viewtopic.php?f=5&t=1215&start=10#p11877
-
-func (v *API) stamp() string {
-	return stamps.New(v.config.CCSPApplicationID)
-}
-
-func (v *API) getDeviceID() (string, error) {
-	uniID, _ := uuid.NewUUID()
-	data := map[string]interface{}{
-		"pushRegId": "1",
-		"pushType":  "GCM",
-		"uuid":      uniID.String(),
-	}
-
-	headers := map[string]string{
-		"ccsp-service-id": v.config.CCSPServiceID,
-		"Content-type":    "application/json;charset=UTF-8",
-		"User-Agent":      "okhttp/3.10.0",
-		"Stamp":           v.stamp(),
-	}
-
-	var resp response
-	req, err := request.New(http.MethodPost, v.config.URI+v.config.DeviceID, request.MarshalJSON(data), headers)
-	if err == nil {
-		err = v.DoJSON(req, &resp)
-	}
-
-	return resp.ResMsg.DeviceID, err
-}
-
-func (v *API) getCookies() (cookieClient *request.Helper, err error) {
-	cookieClient = request.NewHelper(v.log)
-	cookieClient.Client.Jar, err = cookiejar.New(&cookiejar.Options{
-		PublicSuffixList: publicsuffix.List,
-	})
-
-	if err == nil {
-		uri := fmt.Sprintf(
-			"%s/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect",
-			v.config.URI,
-			v.config.CCSPServiceID,
-			v.config.URI,
-		)
-
-		var resp *http.Response
-		if resp, err = cookieClient.Get(uri); err == nil {
-			resp.Body.Close()
-		}
-	}
-
-	return cookieClient, err
-}
-
-func (v *API) setLanguage(cookieClient *request.Helper) error {
-	data := map[string]interface{}{
-		"lang": "en",
-	}
-
-	req, err := request.New(http.MethodPost, v.config.URI+v.config.Lang, request.MarshalJSON(data), request.JSONEncoding)
-	if err == nil {
-		var resp *http.Response
-		if resp, err = cookieClient.Do(req); err == nil {
-			resp.Body.Close()
-		}
-	}
-
-	return err
-}
-
-func (v *API) login(cookieClient *request.Helper) (string, error) {
-	data := map[string]interface{}{
-		"email":    v.user,
-		"password": v.password,
-	}
-
-	req, err := request.New(http.MethodPost, v.config.URI+v.config.Login, request.MarshalJSON(data), request.JSONEncoding)
-	if err != nil {
-		return "", err
-	}
-
-	var redirect struct {
-		RedirectURL string `json:"redirectUrl"`
-	}
-
-	var accCode string
-	if err = cookieClient.DoJSON(req, &redirect); err == nil {
-		if parsed, err := url.Parse(redirect.RedirectURL); err == nil {
-			accCode = parsed.Query().Get("code")
-		}
-	}
-
-	return accCode, err
-}
-
-func (v *API) getToken(accCode string) (string, error) {
-	headers := map[string]string{
-		"Authorization": "Basic " + v.config.TokenAuth,
-		"Content-type":  "application/x-www-form-urlencoded",
-		"User-Agent":    "okhttp/3.10.0",
-	}
-
-	data := url.Values(map[string][]string{
-		"grant_type":   {"authorization_code"},
-		"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
-		"code":         {accCode},
-	})
-
-	req, err := request.New(http.MethodPost, v.config.URI+v.config.AccessToken, strings.NewReader(data.Encode()), headers)
-	if err != nil {
-		return "", err
-	}
-
-	var tokens struct {
-		TokenType   string `json:"token_type"`
-		AccessToken string `json:"access_token"`
-	}
-
-	var accToken string
-	if err = v.DoJSON(req, &tokens); err == nil {
-		accToken = fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)
-	}
-
-	return accToken, err
-}
-
-func (v *API) getVehicles(accToken, did string) (string, error) {
-	headers := map[string]string{
-		"Authorization":       accToken,
-		"ccsp-device-id":      did,
-		"ccsp-application-id": v.config.CCSPApplicationID,
-		"offset":              "1",
-		"User-Agent":          "okhttp/3.10.0",
-		"Stamp":               v.stamp(),
-	}
-
-	req, err := request.New(http.MethodGet, v.config.URI+v.config.Vehicles, nil, headers)
-	if err == nil {
-		var resp response
-		if err = v.DoJSON(req, &resp); err == nil {
-			if len(resp.ResMsg.Vehicles) == 1 {
-				return resp.ResMsg.Vehicles[0].VehicleID, nil
-			}
-
-			err = errors.New("couldn't find vehicle")
-		}
-	}
-
-	return "", err
-}
-
-func (v *API) authFlow() (err error) {
-	v.auth.deviceID, err = v.getDeviceID()
-
-	var cookieClient *request.Helper
-	if err == nil {
-		cookieClient, err = v.getCookies()
-	}
-
-	if err == nil {
-		err = v.setLanguage(cookieClient)
-	}
-
-	var accCode string
-	if err == nil {
-		accCode, err = v.login(cookieClient)
-	}
-
-	if err == nil {
-		v.auth.accToken, err = v.getToken(accCode)
-	}
-
-	if err == nil {
-		v.auth.vehicleID, err = v.getVehicles(v.auth.accToken, v.auth.deviceID)
-	}
-
-	return err
-}
-
-func (v *API) getStatus() (response, error) {
-	var resp response
-
-	if v.auth.accToken == "" {
-		return resp, errAuthFail
-	}
-
-	headers := map[string]string{
-		"Authorization":       v.auth.accToken,
-		"ccsp-device-id":      v.auth.deviceID,
-		"ccsp-application-id": v.config.CCSPApplicationID,
-		"offset":              "1",
-		"User-Agent":          "okhttp/3.10.0",
-		"Stamp":               v.stamp(),
-	}
-
-	uri := fmt.Sprintf(v.config.URI+v.config.Status, v.auth.vehicleID)
-	req, err := request.New(http.MethodGet, uri, nil, headers)
-	if err == nil {
-		err = v.DoJSON(req, &resp)
-
-		if err != nil {
-			// handle http 401, 403
-			if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusUnauthorized, http.StatusForbidden) {
-				err = errAuthFail
-			}
-		}
-
-		if err == nil && resp.RetCode != resOK {
-			err = errors.New("unexpected response")
-			if resp.RetCode == resAuthFail {
-				err = errAuthFail
-			}
-		}
-	}
-
-	return resp, err
-}
-
-// status retrieves the bluelink status response
-func (v *API) statusAPI() (interface{}, error) {
-	res, err := v.getStatus()
-
-	if err != nil && errors.Is(err, errAuthFail) {
-		if err = v.authFlow(); err == nil {
-			res, err = v.getStatus()
-		}
-	}
-
-	// add local timestamp for FinishTime
-	res.timestamp = time.Now()
-
-	return res, err
-}
-
-var _ api.Battery = (*API)(nil)
-
-// SoC implements the api.Vehicle interface
-func (v *API) SoC() (float64, error) {
-	res, err := v.apiG()
-
-	if res, ok := res.(response); err == nil && ok {
-		return float64(res.ResMsg.EvStatus.BatteryStatus), nil
-	}
-
-	return 0, err
-}
-
-var _ api.VehicleFinishTimer = (*API)(nil)
-
-// FinishTime implements the api.VehicleFinishTimer interface
-func (v *API) FinishTime() (time.Time, error) {
-	res, err := v.apiG()
-
-	if res, ok := res.(response); err == nil && ok {
-		remaining := res.ResMsg.EvStatus.RemainTime2.Atc.Value
-
-		if remaining == 0 {
-			return time.Time{}, api.ErrNotAvailable
-		}
-
-		return res.timestamp.Add(time.Duration(remaining) * time.Minute), nil
-	}
-
-	return time.Time{}, err
-}
-
-var _ api.VehicleRange = (*API)(nil)
-
-// Range implements the api.VehicleRange interface
-func (v *API) Range() (int64, error) {
-	res, err := v.apiG()
-
-	if res, ok := res.(response); err == nil && ok {
-		if dist := res.ResMsg.EvStatus.DrvDistance; len(dist) == 1 {
-			return int64(dist[0].RangeByFuel.EvModeRange.Value), nil
-		}
-
-		return 0, api.ErrNotAvailable
-	}
-
-	return 0, err
-}
diff --git a/internal/vehicle/bluelink/identity.go b/internal/vehicle/bluelink/identity.go
new file mode 100644
index 00000000..936a7d51
--- /dev/null
+++ b/internal/vehicle/bluelink/identity.go
@@ -0,0 +1,371 @@
+package bluelink
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/cookiejar"
+	"net/url"
+	"strings"
+
+	"github.com/PuerkitoBio/goquery"
+	"github.com/andig/evcc/util"
+	"github.com/andig/evcc/util/oauth"
+	"github.com/andig/evcc/util/request"
+	"github.com/google/uuid"
+	"golang.org/x/net/publicsuffix"
+	"golang.org/x/oauth2"
+)
+
+const (
+	DeviceIdURL        = "/api/v1/spa/notifications/register"
+	IntegrationInfoURL = "/api/v1/user/integrationinfo"
+	SilentSigninURL    = "/api/v1/user/silentsignin"
+	LanguageURL        = "/api/v1/user/language"
+	LoginURL           = "/api/v1/user/signin"
+	TokenURL           = "/api/v1/user/oauth2/token"
+)
+
+// Config is the bluelink API configuration
+type Config struct {
+	URI               string
+	BrandAuthUrl      string // v2
+	BasicToken        string
+	CCSPServiceID     string
+	CCSPApplicationID string
+}
+
+// API implements the Kia/Hyundai bluelink api.
+// Based on https://github.com/Hacksore/bluelinky.
+type Identity struct {
+	*request.Helper
+	log         *util.Logger
+	config      Config
+	deviceID    string
+	tokenSource oauth2.TokenSource
+}
+
+// NewIdentity creates a new BlueLink API
+func NewIdentity(log *util.Logger, config Config) (*Identity, error) {
+	v := &Identity{
+		log:    log,
+		Helper: request.NewHelper(log),
+		config: config,
+	}
+
+	return v, nil
+}
+
+// Credits to https://openwb.de/forum/viewtopic.php?f=5&t=1215&start=10#p11877
+
+func (v *Identity) stamp() string {
+	return stamps.New(v.config.CCSPApplicationID)
+}
+
+func (v *Identity) getDeviceID() (string, error) {
+	uniID, _ := uuid.NewUUID()
+	data := map[string]interface{}{
+		"pushRegId": "1",
+		"pushType":  "GCM",
+		"uuid":      uniID.String(),
+	}
+
+	headers := map[string]string{
+		"ccsp-service-id": v.config.CCSPServiceID,
+		"Content-type":    "application/json;charset=UTF-8",
+		"User-Agent":      "okhttp/3.10.0",
+		"Stamp":           v.stamp(),
+	}
+
+	var resp struct {
+		RetCode string
+		ResMsg  struct {
+			DeviceID string
+		}
+	}
+
+	req, err := request.New(http.MethodPost, v.config.URI+DeviceIdURL, request.MarshalJSON(data), headers)
+	if err == nil {
+		err = v.DoJSON(req, &resp)
+	}
+
+	return resp.ResMsg.DeviceID, err
+}
+
+func (v *Identity) getCookies() (cookieClient *request.Helper, err error) {
+	cookieClient = request.NewHelper(v.log)
+	cookieClient.Client.Jar, err = cookiejar.New(&cookiejar.Options{
+		PublicSuffixList: publicsuffix.List,
+	})
+
+	if err == nil {
+		uri := fmt.Sprintf(
+			"%s/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect",
+			v.config.URI,
+			v.config.CCSPServiceID,
+			v.config.URI,
+		)
+
+		var resp *http.Response
+		if resp, err = cookieClient.Get(uri); err == nil {
+			resp.Body.Close()
+		}
+	}
+
+	return cookieClient, err
+}
+
+func (v *Identity) setLanguage(cookieClient *request.Helper) error {
+	data := map[string]interface{}{
+		"lang": "en",
+	}
+
+	req, err := request.New(http.MethodPost, v.config.URI+LanguageURL, request.MarshalJSON(data), request.JSONEncoding)
+	if err == nil {
+		var resp *http.Response
+		if resp, err = cookieClient.Do(req); err == nil {
+			resp.Body.Close()
+		}
+	}
+
+	return err
+}
+
+func (v *Identity) brandLogin(cookieClient *request.Helper, user, password string) (string, error) {
+	req, err := request.New(http.MethodGet, v.config.URI+IntegrationInfoURL, nil, request.JSONEncoding)
+
+	var info struct {
+		UserId    string `json:"userId"`
+		ServiceId string `json:"serviceId"`
+	}
+
+	if err == nil {
+		err = cookieClient.DoJSON(req, &info)
+	}
+
+	var action string
+	var resp *http.Response
+
+	if err == nil {
+		uri := fmt.Sprintf(v.config.BrandAuthUrl, v.config.URI, "en", info.ServiceId, info.UserId)
+
+		req, err = request.New(http.MethodGet, uri, nil)
+		if err == nil {
+			if resp, err = cookieClient.Do(req); err == nil {
+				defer resp.Body.Close()
+
+				var doc *goquery.Document
+				if doc, err = goquery.NewDocumentFromReader(resp.Body); err == nil {
+					err = errors.New("form not found")
+
+					if form := doc.Find("form"); form != nil && form.Length() == 1 {
+						var ok bool
+						if action, ok = form.Attr("action"); ok {
+							err = nil
+						}
+					}
+				}
+			}
+		}
+	}
+
+	if err == nil {
+		data := url.Values{
+			"username":     []string{user},
+			"password":     []string{password},
+			"credentialId": []string{""},
+			"rememberMe":   []string{"on"},
+		}
+
+		req, err = request.New(http.MethodPost, action, strings.NewReader(data.Encode()), request.URLEncoding)
+		if err == nil {
+			cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects
+			if resp, err = cookieClient.Do(req); err == nil {
+				defer resp.Body.Close()
+
+				// need 302
+				if resp.StatusCode != http.StatusFound {
+					err = errors.New("missing redirect")
+
+					if doc, err2 := goquery.NewDocumentFromReader(resp.Body); err2 == nil {
+						if span := doc.Find("span[class=kc-feedback-text]"); span != nil && span.Length() == 1 {
+							err = errors.New(span.Text())
+						}
+					}
+				}
+			}
+
+			cookieClient.CheckRedirect = nil
+		}
+	}
+
+	var userId string
+	if err == nil {
+		resp, err = cookieClient.Get(resp.Header.Get("Location"))
+		if err == nil {
+			defer resp.Body.Close()
+
+			userId = resp.Request.URL.Query().Get("userId")
+			if len(userId) == 0 {
+				err = errors.New("usedId not found")
+			}
+		}
+	}
+
+	var code string
+	if err == nil {
+		data := map[string]string{
+			"userId": userId,
+		}
+
+		req, err = request.New(http.MethodPost, v.config.URI+SilentSigninURL, request.MarshalJSON(data), request.JSONEncoding)
+		if err == nil {
+			req.Header.Set("ccsp-service-id", v.config.CCSPServiceID)
+			cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects
+
+			var res struct {
+				RedirectUrl string `json:"redirectUrl"`
+			}
+
+			if err = cookieClient.DoJSON(req, &res); err == nil {
+				var uri *url.URL
+				if uri, err = url.Parse(res.RedirectUrl); err == nil {
+					if code = uri.Query().Get("code"); len(code) == 0 {
+						err = errors.New("code not found")
+					}
+				}
+			}
+		}
+	}
+
+	return code, err
+}
+
+func (v *Identity) bluelinkLogin(cookieClient *request.Helper, user, password string) (string, error) {
+	data := map[string]interface{}{
+		"email":    user,
+		"password": password,
+	}
+
+	req, err := request.New(http.MethodPost, v.config.URI+LoginURL, request.MarshalJSON(data), request.JSONEncoding)
+	if err != nil {
+		return "", err
+	}
+
+	var redirect struct {
+		RedirectURL string `json:"redirectUrl"`
+	}
+
+	var accCode string
+	if err = cookieClient.DoJSON(req, &redirect); err == nil {
+		if parsed, err := url.Parse(redirect.RedirectURL); err == nil {
+			accCode = parsed.Query().Get("code")
+		}
+	}
+
+	return accCode, err
+}
+
+func (v *Identity) exchangeCode(accCode string) (oauth.Token, error) {
+	headers := map[string]string{
+		"Authorization": "Basic " + v.config.BasicToken,
+		"Content-type":  "application/x-www-form-urlencoded",
+		"User-Agent":    "okhttp/3.10.0",
+	}
+
+	data := url.Values(map[string][]string{
+		"grant_type":   {"authorization_code"},
+		"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
+		"code":         {accCode},
+	})
+
+	var token oauth.Token
+
+	req, err := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers)
+	if err == nil {
+		err = v.DoJSON(req, &token)
+	}
+
+	return token, err
+}
+
+// Refresh implements token refresh
+func (v *Identity) Refresh(token *oauth2.Token) (*oauth2.Token, error) {
+	headers := map[string]string{
+		"Authorization": "Basic " + v.config.BasicToken,
+		"Content-type":  "application/x-www-form-urlencoded",
+		"User-Agent":    "okhttp/3.10.0",
+	}
+
+	data := url.Values(map[string][]string{
+		"grant_type":    {"refresh_token"},
+		"redirect_uri":  {"https://www.getpostman.com/oauth2/callback"},
+		"refresh_token": {token.RefreshToken},
+	})
+
+	req, err := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers)
+
+	var res oauth.Token
+	if err == nil {
+		err = v.DoJSON(req, &res)
+	}
+
+	return (*oauth2.Token)(&res), err
+}
+
+func (v *Identity) Login(user, password string) (err error) {
+	v.deviceID, err = v.getDeviceID()
+
+	var cookieClient *request.Helper
+	if err == nil {
+		cookieClient, err = v.getCookies()
+	}
+
+	if err == nil {
+		err = v.setLanguage(cookieClient)
+	}
+
+	var code string
+	if err == nil {
+		// try new login first, then fallback
+		if code, err = v.brandLogin(cookieClient, user, password); err != nil {
+			code, err = v.bluelinkLogin(cookieClient, user, password)
+		}
+
+		if err != nil {
+			err = fmt.Errorf("login failed: %w", err)
+		}
+	}
+
+	if err == nil {
+		var token oauth.Token
+		if token, err = v.exchangeCode(code); err == nil {
+			v.tokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v)
+		}
+	}
+
+	if err != nil {
+		err = fmt.Errorf("login failed: %w", err)
+	}
+
+	return err
+}
+
+// Request creates authenticated request
+func (v *Identity) Request(method, path string) (*http.Request, error) {
+	token, err := v.tokenSource.Token()
+	if err != nil {
+		return nil, err
+	}
+
+	headers := map[string]string{
+		"Authorization":       "Bearer " + token.AccessToken,
+		"ccsp-device-id":      v.deviceID,
+		"ccsp-application-id": v.config.CCSPApplicationID,
+		"offset":              "1",
+		"User-Agent":          "okhttp/3.10.0",
+		"Stamp":               v.stamp(),
+	}
+
+	return request.New(method, v.config.URI+path, nil, headers)
+}
diff --git a/internal/vehicle/ford.go b/internal/vehicle/ford.go
index c9380801..e4eb98f8 100644
--- a/internal/vehicle/ford.go
+++ b/internal/vehicle/ford.go
@@ -1,6 +1,7 @@
 package vehicle
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -54,7 +55,7 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	}
 
 	if cc.User == "" || cc.Password == "" {
-		return nil, fmt.Errorf("missing credentials")
+		return nil, errors.New("missing credentials")
 	}
 
 	log := util.NewLogger("ford")
@@ -77,7 +78,7 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return v.vehicleStatus()
 	}, cc.Cache).InterfaceGetter()
 
-	if cc.VIN == "" {
+	if err == nil && cc.VIN == "" {
 		v.vin, err = findVehicle(v.vehicles())
 		if err == nil {
 			log.DEBUG.Printf("found vehicle: %v", v.vin)
diff --git a/internal/vehicle/hyundai.go b/internal/vehicle/hyundai.go
index 6b673278..4b0bcb03 100644
--- a/internal/vehicle/hyundai.go
+++ b/internal/vehicle/hyundai.go
@@ -1,6 +1,8 @@
 package vehicle
 
 import (
+	"errors"
+	"strings"
 	"time"
 
 	"github.com/andig/evcc/api"
@@ -24,6 +26,7 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Title          string
 		Capacity       int64
 		User, Password string
+		VIN            string
 		Cache          time.Duration
 	}{
 		Cache: interval,
@@ -33,19 +36,49 @@ func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, err
 	}
 
+	if cc.User == "" || cc.Password == "" {
+		return nil, errors.New("missing credentials")
+	}
+
 	settings := bluelink.Config{
 		URI:               "https://prd.eu-ccapi.hyundai.com:8080",
-		TokenAuth:         "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==",
+		BasicToken:        "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==",
 		CCSPServiceID:     "6d477c38-3ca4-4cf3-9557-2a1929a94654",
 		CCSPApplicationID: "99cfff84-f4e2-4be8-a5ed-e5b755eb6581",
+		BrandAuthUrl:      "https://eu-account.hyundai.com/auth/realms/euhyundaiidm/protocol/openid-connect/auth?client_id=97516a3c-2060-48b4-98cd-8e7dcd3c47b2&scope=openid%%20profile%%20email%%20phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s",
 	}
 
 	log := util.NewLogger("hyundai")
-	api, err := bluelink.New(log, cc.User, cc.Password, cc.Cache, settings)
+	identity, err := bluelink.NewIdentity(log, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := identity.Login(cc.User, cc.Password); err != nil {
+		return nil, err
+	}
+
+	api := bluelink.NewAPI(log, identity, cc.Cache)
+
+	vehicles, err := api.Vehicles()
 	if err != nil {
 		return nil, err
 	}
 
+	if cc.VIN == "" && len(vehicles) == 1 {
+		api.Vehicle = vehicles[0]
+	} else {
+		for _, vehicle := range vehicles {
+			if vehicle.Vin == strings.ToUpper(cc.VIN) {
+				api.Vehicle = vehicle
+			}
+		}
+	}
+
+	if len(api.Vehicle.Vin) == 0 {
+		return nil, errors.New("vin not found")
+	}
+
 	v := &Hyundai{
 		embed: &embed{cc.Title, cc.Capacity},
 		API:   api,
diff --git a/internal/vehicle/kia.go b/internal/vehicle/kia.go
index 5584ce9b..02c8ccb7 100644
--- a/internal/vehicle/kia.go
+++ b/internal/vehicle/kia.go
@@ -1,6 +1,8 @@
 package vehicle
 
 import (
+	"errors"
+	"strings"
 	"time"
 
 	"github.com/andig/evcc/api"
@@ -24,6 +26,7 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Title          string
 		Capacity       int64
 		User, Password string
+		VIN            string
 		Cache          time.Duration
 	}{
 		Cache: interval,
@@ -33,19 +36,49 @@ func NewKiaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, err
 	}
 
+	if cc.User == "" || cc.Password == "" {
+		return nil, errors.New("missing credentials")
+	}
+
 	settings := bluelink.Config{
 		URI:               "https://prd.eu-ccapi.kia.com:8080",
-		TokenAuth:         "ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==",
+		BasicToken:        "ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==",
 		CCSPServiceID:     "fdc85c00-0a2f-4c64-bcb4-2cfb1500730a",
 		CCSPApplicationID: "693a33fa-c117-43f2-ae3b-61a02d24f417",
+		BrandAuthUrl:      "https://eu-account.kia.com/auth/realms/eukiaidm/protocol/openid-connect/auth?client_id=f4d531c7-1043-444d-b09a-ad24bd913dd4&scope=openid%%20profile%%20email%%20phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s",
 	}
 
 	log := util.NewLogger("kia")
-	api, err := bluelink.New(log, cc.User, cc.Password, cc.Cache, settings)
+	identity, err := bluelink.NewIdentity(log, settings)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := identity.Login(cc.User, cc.Password); err != nil {
+		return nil, err
+	}
+
+	api := bluelink.NewAPI(log, identity, cc.Cache)
+
+	vehicles, err := api.Vehicles()
 	if err != nil {
 		return nil, err
 	}
 
+	if cc.VIN == "" && len(vehicles) == 1 {
+		api.Vehicle = vehicles[0]
+	} else {
+		for _, vehicle := range vehicles {
+			if vehicle.Vin == strings.ToUpper(cc.VIN) {
+				api.Vehicle = vehicle
+			}
+		}
+	}
+
+	if len(api.Vehicle.Vin) == 0 {
+		return nil, errors.New("vin not found")
+	}
+
 	v := &Kia{
 		embed: &embed{cc.Title, cc.Capacity},
 		API:   api,
diff --git a/provider/script.go b/provider/script.go
index 2ca8891b..67660ca5 100644
--- a/provider/script.go
+++ b/provider/script.go
@@ -3,12 +3,15 @@ package provider
 import (
 	"context"
 	"errors"
+	"fmt"
 	"os/exec"
 	"strconv"
 	"strings"
 	"time"
 
 	"github.com/andig/evcc/util"
+	"github.com/andig/evcc/util/jq"
+	"github.com/itchyny/gojq"
 	"github.com/kballard/go-shellquote"
 )
 
@@ -21,6 +24,7 @@ type Script struct {
 	updated time.Time
 	val     string
 	err     error
+	jq      *gojq.Query
 }
 
 func init() {
@@ -33,6 +37,7 @@ func NewScriptProviderFromConfig(other map[string]interface{}) (IntProvider, err
 		Cmd     string
 		Timeout time.Duration
 		Cache   time.Duration
+		Jq      string
 	}{
 		Timeout: 5 * time.Second,
 	}
@@ -41,18 +46,27 @@ func NewScriptProviderFromConfig(other map[string]interface{}) (IntProvider, err
 		return nil, err
 	}
 
-	return NewScriptProvider(cc.Cmd, cc.Timeout, cc.Cache)
+	return NewScriptProvider(cc.Cmd, cc.Timeout, cc.Jq, cc.Cache)
 }
 
 // NewScriptProvider creates a script provider.
 // Script execution is aborted after given timeout.
-func NewScriptProvider(script string, timeout time.Duration, cache time.Duration) (*Script, error) {
+func NewScriptProvider(script string, timeout time.Duration, jq string, cache time.Duration) (*Script, error) {
 	s := &Script{
 		log:     util.NewLogger("script"),
 		script:  script,
 		timeout: timeout,
 		cache:   cache,
 	}
+	
+	if jq != "" {
+		op, err := gojq.Parse(jq)
+		if err != nil {
+			return nil, fmt.Errorf("invalid jq query '%s': %w", jq, err)
+		}
+
+		s.jq = op
+	}
 
 	return s, nil
 }
@@ -83,6 +97,7 @@ func (e *Script) exec(script string) (string, error) {
 	}
 
 	e.log.TRACE.Printf("%s: %s", strings.Join(args, " "), s)
+
 	return s, nil
 }
 
@@ -92,8 +107,15 @@ func (e *Script) StringGetter() func() (string, error) {
 		if time.Since(e.updated) > e.cache {
 			e.val, e.err = e.exec(e.script)
 			e.updated = time.Now()
-		}
 
+			if e.err == nil && e.jq != nil {
+				var v interface{}
+				if v, e.err = jq.Query(e.jq, []byte(e.val)); e.err == nil {
+					e.val = fmt.Sprintf("%v", v)
+				}
+			}
+		}
+		
 		return e.val, e.err
 	}
 }
diff --git a/server/influxdb.go b/server/influxdb.go
index eb683e2f..6e733708 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -83,8 +83,19 @@ func (m *Influx) Run(loadPoints []core.LoadPointAPI, in <-chan util.Param) {
 		}
 	}()
 
+	// track active vehicle per loadpoint
+	vehicles := make(map[int]string)
+
 	// add points to batch for async writing
 	for param := range in {
+		// vehicle name
+		if param.LoadPoint != nil {
+			if name, ok := param.Val.(string); ok && param.Key == "socTitle" {
+				vehicles[*param.LoadPoint] = name
+				continue
+			}
+		}
+
 		if !m.supportedType(param) {
 			continue
 		}
@@ -92,6 +103,7 @@ func (m *Influx) Run(loadPoints []core.LoadPointAPI, in <-chan util.Param) {
 		tags := map[string]string{}
 		if param.LoadPoint != nil {
 			tags["loadpoint"] = loadPoints[*param.LoadPoint].Name()
+			tags["vehicle"] = vehicles[*param.LoadPoint]
 		}
 
 		fields := map[string]interface{}{}
diff --git a/soc/Dockerfile b/soc/Dockerfile
index 350f64a8..51365b5e 100644
--- a/soc/Dockerfile
+++ b/soc/Dockerfile
@@ -1,24 +1,26 @@
 # STEP 1 build executable binary
-FROM golang:1.16-alpine as builder
+# FROM golang:1.16-alpine as builder
 
-# Install git + SSL ca certificates.
-# Git is required for fetching the dependencies.
-# Ca-certificates is required to call HTTPS endpoints.
-RUN apk update && apk add --no-cache git ca-certificates tzdata alpine-sdk && update-ca-certificates
+# # Install git + SSL ca certificates.
+# # Git is required for fetching the dependencies.
+# # Ca-certificates is required to call HTTPS endpoints.
+# RUN apk update && apk add --no-cache git ca-certificates tzdata alpine-sdk && update-ca-certificates
 
-WORKDIR /build
+# WORKDIR /build
 
-# install go tools and cache modules
-COPY Makefile .
-COPY go.mod .
-COPY go.sum .
-RUN make install
-RUN go mod download
+# # install go tools and cache modules
+# COPY Makefile .
+# COPY go.mod .
+# COPY go.sum .
+# RUN make install
+# RUN go mod download
 
-# build
-COPY . .
-RUN make server
+# # build
+# COPY . .
+# RUN make server
 
+FROM alpine:3.13 as builder
+RUN apk update && apk add --no-cache git ca-certificates tzdata && update-ca-certificates
 
 # STEP 2 build a small image including module support
 FROM alpine:3.13
@@ -28,6 +30,6 @@ 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/soc-server /usr/local/bin/soc-server
+COPY soc-server /usr/local/bin/soc-server
 
 CMD [ "soc-server" ]
diff --git a/soc/cert/server/cert.go b/soc/cert/server/cert.go
index 4a2095aa..bf4c1e25 100644
--- a/soc/cert/server/cert.go
+++ b/soc/cert/server/cert.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"crypto/tls"
 	_ "embed"
+	"encoding/base64"
 
 	"github.com/andig/evcc/util"
 )
@@ -15,7 +16,11 @@ var cert []byte
 var key []byte
 
 func init() {
-	key = []byte(util.Getenv("SERVER_KEY"))
+	var err error
+	key, err = base64.StdEncoding.DecodeString(util.Getenv("SERVER_KEY"))
+	if err != nil {
+		panic(err)
+	}
 }
 
 func PEM() []byte {
diff --git a/soc/server/Dockerfile b/soc/server/Dockerfile
deleted file mode 100644
index 312ae20f..00000000
--- a/soc/server/Dockerfile
+++ /dev/null
@@ -1,36 +0,0 @@
-# STEP 1 build executable binary
-FROM golang:1.16-alpine as builder
-
-# Install git + SSL ca certificates.
-# Git is required for fetching the dependencies.
-# Ca-certificates is required to call HTTPS endpoints.
-RUN apk update && apk add --no-cache git ca-certificates tzdata alpine-sdk && update-ca-certificates
-
-WORKDIR /build
-
-# install go tools and cache modules
-COPY go.mod .
-COPY go.sum .
-RUN go mod download
-
-# prepare
-COPY . .
-
-# build
-RUN make build
-
-
-# STEP 3 build a small image including module support
-FROM alpine:3.13
-
-WORKDIR /app
-
-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-cloud /usr/local/bin/evcc-cloud
-
-# ENTRYPOINT [ "/app/entrypoint.sh" ]
-CMD [ "evcc-cloud" ]
diff --git a/soc/server/server/init.go b/soc/server/server/init.go
index fdc7aa37..3c2cde24 100644
--- a/soc/server/server/init.go
+++ b/soc/server/server/init.go
@@ -51,7 +51,7 @@ func loadTLSCredentials() (*tls.Config, error) {
 }
 
 func Run() {
-	fmt.Println("grpc:", ":"+port)
+	log.Println("grpc:", ":"+port)
 
 	listener, err := net.Listen("tcp", ":"+port)
 	if err != nil {
diff --git a/soc/server/ui/ui.go b/soc/server/ui/ui.go
index e4c1aa45..dd4fb5fa 100644
--- a/soc/server/ui/ui.go
+++ b/soc/server/ui/ui.go
@@ -225,7 +225,9 @@ func Run() {
 			Cache:      autocert.DirCache(sslCertDir),
 		}
 
-		go log.Fatal(http.ListenAndServe(":http", certManager.HTTPHandler(nil)))
+		go func() {
+			log.Fatal(http.ListenAndServe(":http", certManager.HTTPHandler(nil)))
+		}()
 
 		s.Addr = ":https"
 		s.TLSConfig = certManager.TLSConfig()


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 0f5f7560..1883e55e 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -207,13 +207,20 @@ func TestPVHysteresis(t *testing.T) {
 			{-400, dt - 1, 0},
 			{-400, dt + 1, 0},
 		}},
-		// keep disabled when threshold not met
+		// keep disabled when threshold (lower minCurrent) not met
 		{false, -500, 0, []se{
 			{-400, 0, 0},
 			{-400, 1, 0},
 			{-400, dt - 1, 0},
 			{-400, dt + 1, 0},
 		}},
+		// keep disabled when threshold (higher minCurrent) not met
+		{false, -7 * 100 * 10, 0, []se{
+			{-6 * 100 * 10, 0, 0},
+			{-6 * 100 * 10, 1, 0},
+			{-6 * 100 * 10, dt - 1, 0},
+			{-6 * 100 * 10, dt + 1, 0},
+		}},
 		// enable when threshold met
 		{false, -500, 0, []se{
 			{-500, 0, 0},
diff --git a/internal/charger/tplink/types_test.go b/internal/charger/tplink/types_test.go
new file mode 100644
index 00000000..ae175447
--- /dev/null
+++ b/internal/charger/tplink/types_test.go
@@ -0,0 +1,88 @@
+package tplink
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestUnmarshalTPLinkSystemResponses(t *testing.T) {
+	var sysresp SystemResponse
+
+	// Test set_relay_state response
+	jsonstr := `{"system":{"set_relay_state":{"err_code":0}}}`
+	if err := json.Unmarshal([]byte(jsonstr), &sysresp); err != nil {
+		t.Error(err)
+	}
+	if sysresp.System.SetRelayState.ErrCode != 0 {
+		t.Error("SetRelayState.ErrCode")
+	}
+
+	// Test get_sysinfo response
+	jsonstr = `{"system":{"get_sysinfo":{"err_code":0,"sw_ver":"1.2.6 Build 200727 Rel.120821","hw_ver":"1.0","type":"IOT.SMARTPLUGSWITCH","model":"HS110(EU)","mac":"50:C7:BF:42:60:9B","deviceId":"80068B6B73AAD8C4A4D0F7B9AB5F8B1B1838EEAC","hwId":"45E29DA8382494D2E82688B52A0B2EB5","fwId":"00000000000000000000000000000000","oemId":"3D341ECE302C0642C99E31CE2430544B","alias":"evcc-charger","dev_name":"Wi-Fi Smart Plug With Energy Monitoring","icon_hash":"","relay_state":1,"on_time":110,"active_mode":"schedule","feature":"TIM:ENE","updating":0,"rssi":-54,"led_off":0,"latitude":49.817090,"longitude":9.056194}}}`
+	if err := json.Unmarshal([]byte(jsonstr), &sysresp); err != nil {
+		t.Error(err)
+	}
+	if sysresp.System.GetSysinfo.ErrCode != 0 {
+		t.Error("GetSysinfo.ErrCode")
+	}
+	if sysresp.System.GetSysinfo.SwVer != "1.2.6 Build 200727 Rel.120821" {
+		t.Error("GetSysinfo.SwVer")
+	}
+	if sysresp.System.GetSysinfo.Model != "HS110(EU)" {
+		t.Error("GetSysinfo.Model")
+	}
+	if sysresp.System.GetSysinfo.Alias != "evcc-charger" {
+		t.Error("GetSysinfo.Alias")
+	}
+	if sysresp.System.GetSysinfo.RelayState != 1 {
+		t.Error("GetSysinfo.RelayState")
+	}
+	if sysresp.System.GetSysinfo.Feature != "TIM:ENE" {
+		t.Error("GetSysinfo.Feature")
+	}
+
+	// Test 1st emeter generation response
+	var emeresp EmeterResponse
+	jsonstr = `{"emeter":{"get_realtime":{"current":0.033759,"voltage":234.824322,"power":3.121391,"total":0.015000,"err_code":0}}}`
+	if err := json.Unmarshal([]byte(jsonstr), &emeresp); err != nil {
+		t.Error(err)
+	}
+	if emeresp.Emeter.GetRealtime.Current != 0.033759 {
+		t.Error("GetRealtime.Current")
+	}
+	if emeresp.Emeter.GetRealtime.Voltage != 234.824322 {
+		t.Error("GetRealtime.Voltage")
+	}
+	if emeresp.Emeter.GetRealtime.Power != 3.121391 {
+		t.Error("GetRealtime.Power")
+	}
+	if emeresp.Emeter.GetRealtime.Total != 0.015000 {
+		t.Error("GetRealtime.Total")
+	}
+	if emeresp.Emeter.GetRealtime.ErrCode != 0 {
+		t.Error("GetRealtime.ErrCode")
+	}
+
+	// Test 2nd emeter generation response
+	var emeresp2 EmeterResponse
+	jsonstr = ` {"emeter":{"get_realtime":{"voltage_mv":237119,"current_ma":218,"power_mw":31259,"total_wh":107,"err_code":0}}}`
+	if err := json.Unmarshal([]byte(jsonstr), &emeresp2); err != nil {
+		t.Error(err)
+	}
+	if emeresp2.Emeter.GetRealtime.CurrentMa != 218 {
+		t.Error("GetRealtime.CurrentMa")
+	}
+	if emeresp2.Emeter.GetRealtime.VoltageMv != 237119 {
+		t.Error("GetRealtime.VoltageMv")
+	}
+	if emeresp2.Emeter.GetRealtime.PowerMw != 31259 {
+		t.Error("GetRealtime.PowerMw")
+	}
+	if emeresp2.Emeter.GetRealtime.TotalWh != 107 {
+		t.Error("GetRealtime.TotalWh")
+	}
+	if emeresp2.Emeter.GetRealtime.ErrCode != 0 {
+		t.Error("GetRealtime.ErrCode")
+	}
+
+}