Repository: evcc-io/evcc
Pull request #3817 (2022-07-12 08:26:59)

Pracht: use current and total current
Fix https://github.com/evcc-io/evcc/issues/3738

andig: Leider scheint auch dieser PR nicht zu funktionieren. Mir fällt nix mehr ein :(

Patch:
diff --git a/charger/kse.go b/charger/kse.go
new file mode 100644
index 00000000..a5ad3f88
--- /dev/null
+++ b/charger/kse.go
@@ -0,0 +1,239 @@
+package charger
+
+// LICENSE
+
+// Copyright (c) 2022 premultiply
+
+// This module is NOT covered by the MIT license. All rights reserved.
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+import (
+	"encoding/binary"
+	"fmt"
+	"strings"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/modbus"
+	"github.com/evcc-io/evcc/util/sponsor"
+)
+
+// KSE charger implementation
+type KSE struct {
+	log  *util.Logger
+	conn *modbus.Connection
+	curr uint16
+}
+
+const (
+	kseRegSetMaxCurrent       = 0x03 // Externe Stromvorgabe via Bussystem / Ladefreigabe
+	kseRegChargeMode          = 0x0E // Lademodus
+	kseRegVehicleState        = 0x10 // State der Statemachine
+	kseRegCurrents            = 0x14 // Phasenstrom (3)
+	kseRegCurrentLoadedEnergy = 0x17 // Zwischen anstecken und abstecken geladene Energie (10 Wh)
+	kseRegActualPower         = 0x18 // Aktuelle Ladeleistung (W)
+	kseRegFirmwareVersion     = 0x30 // Firmware Version
+	kseRegRFIDinstalled       = 0x31 // RFID-Leser vorhanden
+	kseRegRelayMode           = 0x35 // Umschalten 1 phasiges oder 3 phasiges Laden
+	kseRegNFCTransactionID    = 0x67 // Tag ID (8 Bytes)
+)
+
+func init() {
+	registry.Add("kse", NewKSEFromConfig)
+}
+
+// NewKSEFromConfig creates a KSE charger from generic config
+func NewKSEFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := modbus.Settings{
+		ID:       100,
+		Baudrate: 9600,
+		Comset:   "8E1",
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewKSE(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.ID)
+}
+
+// NewKSE creates KSE charger
+func NewKSE(uri, device, comset string, baudrate int, slaveID uint8) (api.Charger, error) {
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, modbus.Rtu, slaveID)
+	if err != nil {
+		return nil, err
+	}
+
+	if !sponsor.IsAuthorized() {
+		return nil, api.ErrSponsorRequired
+	}
+
+	log := util.NewLogger("kse")
+	conn.Logger(log.TRACE)
+
+	wb := &KSE{
+		log:  log,
+		conn: conn,
+		curr: 6, // assume min current
+	}
+
+	return wb, err
+}
+
+// Status implements the api.Charger interface
+func (wb *KSE) Status() (api.ChargeStatus, error) {
+	b, err := wb.conn.ReadInputRegisters(kseRegVehicleState, 1)
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	s := binary.BigEndian.Uint16(b)
+
+	switch s {
+	case 0, 1, 3:
+		return api.StatusA, nil
+	case 4:
+		return api.StatusB, nil
+	case 5:
+		return api.StatusC, nil
+	case 6, 7, 8:
+		return api.StatusE, nil
+	default:
+		return api.StatusNone, fmt.Errorf("invalid status: %d", s)
+	}
+}
+
+// Enabled implements the api.Charger interface
+func (wb *KSE) Enabled() (bool, error) {
+	b, err := wb.conn.ReadHoldingRegisters(kseRegSetMaxCurrent, 1)
+	if err != nil {
+		return false, err
+	}
+
+	cur := binary.BigEndian.Uint16(b)
+
+	return cur != 0, nil
+}
+
+// Enable implements the api.Charger interface
+func (wb *KSE) Enable(enable bool) error {
+	var u uint16
+	if enable {
+		u = wb.curr
+	}
+
+	_, err := wb.conn.WriteSingleRegister(kseRegSetMaxCurrent, u)
+
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (wb *KSE) MaxCurrent(current int64) error {
+	if current < 6 {
+		return fmt.Errorf("invalid current %d", current)
+	}
+
+	_, err := wb.conn.WriteSingleRegister(kseRegSetMaxCurrent, uint16(current))
+	if err == nil {
+		wb.curr = uint16(current)
+	}
+
+	return err
+}
+
+var _ api.Meter = (*KSE)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (wb *KSE) CurrentPower() (float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(kseRegActualPower, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint16(b)), err
+}
+
+var _ api.ChargeRater = (*KSE)(nil)
+
+// ChargedEnergy implements the api.MeterEnergy interface
+func (wb *KSE) ChargedEnergy() (float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(kseRegCurrentLoadedEnergy, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint16(b)) / 100, err
+}
+
+var _ api.MeterCurrent = (*KSE)(nil)
+
+// Currents implements the api.MeterCurrent interface
+func (wb *KSE) Currents() (float64, float64, float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(kseRegCurrents, 3)
+	if err != nil {
+		return 0, 0, 0, err
+	}
+
+	var curr [3]float64
+	for l := 0; l < 3; l++ {
+		curr[l] = float64(binary.BigEndian.Uint16(b[2*l:])) / 1e3
+	}
+
+	return curr[0], curr[1], curr[2], nil
+}
+
+var _ api.ChargePhases = (*KSE)(nil)
+
+// Phases1p3p implements the api.ChargePhases interface
+func (wb *KSE) Phases1p3p(phases int) error {
+	var b uint16 = 0
+	if phases == 1 {
+		b = 1 // 1p
+	}
+
+	_, err := wb.conn.WriteSingleRegister(kseRegRelayMode, b)
+	return err
+}
+
+var _ api.Identifier = (*BenderCC)(nil)
+
+// Identify implements the api.Identifier interface
+func (wb *KSE) Identify() (string, error) {
+	id, err := wb.conn.ReadHoldingRegisters(kseRegNFCTransactionID, 4)
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(string(id)), nil
+}
+
+var _ api.Diagnosis = (*KSE)(nil)
+
+// Diagnose implements the api.Diagnosis interface
+func (wb *KSE) Diagnose() {
+	if b, err := wb.conn.ReadInputRegisters(kseRegFirmwareVersion, 1); err == nil {
+		fmt.Printf("\tFirmware:\t%d.%d\n", b[0], b[1])
+	}
+	if b, err := wb.conn.ReadInputRegisters(kseRegChargeMode, 1); err == nil {
+		fmt.Printf("\tCharge Mode:\t%d\n", binary.BigEndian.Uint16(b))
+	}
+	if b, err := wb.conn.ReadInputRegisters(kseRegVehicleState, 1); err == nil {
+		fmt.Printf("\tState:\t%d\n", binary.BigEndian.Uint16(b))
+	}
+	if b, err := wb.conn.ReadDiscreteInputs(kseRegRFIDinstalled, 1); err == nil {
+		fmt.Printf("\tRFID:\t%t\n", binary.BigEndian.Uint16(b) != 0)
+	}
+	if b, err := wb.conn.ReadHoldingRegisters(kseRegNFCTransactionID, 4); err == nil {
+		fmt.Printf("\tNFC ID:\t%s\n", b)
+	}
+}
diff --git a/charger/prachtalpha.go b/charger/prachtalpha.go
index 3a0dcbc8..2eb55733 100644
--- a/charger/prachtalpha.go
+++ b/charger/prachtalpha.go
@@ -36,8 +36,9 @@ type PrachtAlpha struct {
 }
 
 const (
-	prachtMaxCurrent = 40004 - 40001
-	prachtStatus     = 30107 - 30001
+	prachtTotalCurrent = 40003 - 40001 // total limit of all connectors
+	prachtMaxCurrent   = 40004 - 40001 // +1 for second connector
+	prachtStatus       = 30107 - 30001 // +1 for second connector
 )
 
 func init() {
@@ -49,11 +50,11 @@ func init() {
 // NewPrachtAlphaFromConfig creates a PrachtAlpha charger from generic config
 func NewPrachtAlphaFromConfig(other map[string]interface{}) (api.Charger, error) {
 	cc := struct {
-		Vehicle         uint16
+		Connector       uint16
 		modbus.Settings `mapstructure:",squash"`
 		Timeout         time.Duration
 	}{
-		Vehicle: 1,
+		Connector: 1,
 		Settings: modbus.Settings{
 			ID: 1,
 		},
@@ -63,7 +64,7 @@ func NewPrachtAlphaFromConfig(other map[string]interface{}) (api.Charger, error)
 		return nil, err
 	}
 
-	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout, cc.Vehicle)
+	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout, cc.Connector)
 }
 
 // NewPrachtAlpha creates PrachtAlpha charger
@@ -130,7 +131,13 @@ func (wb *PrachtAlpha) Enabled() (bool, error) {
 		return false, err
 	}
 
-	return binary.BigEndian.Uint16(b) > 0, nil
+	// get total current (https://github.com/evcc-io/evcc/issues/3738)
+	tot, err := wb.conn.ReadInputRegisters(prachtTotalCurrent, 1)
+	if err != nil {
+		return false, err
+	}
+
+	return binary.BigEndian.Uint16(b) > 0 && binary.BigEndian.Uint16(tot) > 0, nil
 }
 
 // Enable implements the api.Charger interface
@@ -146,6 +153,12 @@ func (wb *PrachtAlpha) Enable(enable bool) error {
 func (wb *PrachtAlpha) setCurrent(current uint16) error {
 	reg := wb.register(prachtMaxCurrent)
 	_, err := wb.conn.WriteSingleRegister(reg, current)
+
+	// set total current (https://github.com/evcc-io/evcc/issues/3738)
+	if err == nil {
+		_, err = wb.conn.WriteSingleRegister(prachtTotalCurrent, current)
+	}
+
 	return err
 }
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index ad44bae3..2230a8c5 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -23,6 +23,8 @@ import (
 	"github.com/avast/retry-go/v3"
 	"github.com/benbjohnson/clock"
 	"github.com/cjrd/allocate"
+	"github.com/emirpasic/gods/queues"
+	aq "github.com/emirpasic/gods/queues/arrayqueue"
 )
 
 const (
@@ -150,6 +152,8 @@ type LoadPoint struct {
 	chargeRemainingDuration time.Duration // Remaining charge duration
 	chargeRemainingEnergy   float64       // Remaining charge energy in Wh
 	progress                *Progress     // Step-wise progress indicator
+
+	tasks queues.Queue // tasks to be executed
 }
 
 // NewLoadPointFromConfig creates a new loadpoint
@@ -240,8 +244,7 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
 		lp.log.WARN.Printf("locking phase config to %dp for switchable charger", lp.DefaultPhases)
 	}
 
-	// allow target charge handler to access loadpoint
-	lp.socTimer = soc.NewTimer(lp.log, &adapter{LoadPoint: lp})
+	// validate thresholds
 	if lp.Enable.Threshold > lp.Disable.Threshold {
 		lp.log.WARN.Printf("PV mode enable threshold (%.0fW) is larger than disable threshold (%.0fW)", lp.Enable.Threshold, lp.Disable.Threshold)
 	} else if lp.Enable.Threshold > 0 {
@@ -270,8 +273,12 @@ func NewLoadPoint(log *util.Logger) *LoadPoint {
 		Disable:       ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
 		GuardDuration: 5 * time.Minute,
 		progress:      NewProgress(0, 10), // soc progress indicator
+		tasks:         aq.New(),
 	}
 
+	// allow target charge handler to access loadpoint
+	lp.socTimer = soc.NewTimer(lp.log, &adapter{LoadPoint: lp})
+
 	return lp
 }
 
@@ -438,12 +445,13 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 
 	// remove active vehicle if not default
 	if lp.vehicle != lp.defaultVehicle {
-		lp.setActiveVehicle(nil)
+		lp.setActiveVehicle(lp.defaultVehicle)
 		lp.unpublishVehicle()
 	}
 
 	// set default mode on disconnect
 	if lp.ResetOnDisconnect {
+		// TODO respect defaultVehicle
 		lp.applyAction(lp.onDisconnect)
 	}
 
@@ -786,6 +794,9 @@ func (lp *LoadPoint) selectVehicleByID(id string) api.Vehicle {
 
 // setActiveVehicle assigns currently active vehicle and configures soc estimator
 func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
+	lp.Lock()
+	defer lp.Unlock()
+
 	if lp.vehicle == vehicle {
 		return
 	}
@@ -803,24 +814,23 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
 	lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
 
 	if lp.vehicle = vehicle; vehicle != nil {
+		lp.socUpdated = time.Time{}
+
 		lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, lp.SoC.Estimate)
 
 		lp.publish("vehiclePresent", true)
 		lp.publish("vehicleTitle", lp.vehicle.Title())
 		lp.publish("vehicleCapacity", lp.vehicle.Capacity())
 
-		// publish odometer once
-		if vs, ok := lp.vehicle.(api.VehicleOdometer); ok {
-			if odo, err := vs.Odometer(); err == nil {
-				lp.log.DEBUG.Printf("vehicle odometer: %.0fkm", odo)
-				lp.publish("vehicleOdometer", odo)
-			} else {
-				lp.log.ERROR.Printf("vehicle odometer: %v", err)
-			}
-		}
+		// release lock to unblock api
+		lp.Unlock()
 
+		lp.addTask(lp.vehicleOdometer)
 		lp.applyAction(vehicle.OnIdentified())
 
+		// re-apply lock to match defer above
+		lp.Lock()
+
 		lp.progress.Reset()
 	} else {
 		lp.socEstimator = nil
@@ -912,6 +922,18 @@ func (lp *LoadPoint) identifyVehicleByStatus() {
 	}
 }
 
+// vehicleOdometer updates odometer
+func (lp *LoadPoint) vehicleOdometer() {
+	if vs, ok := lp.vehicle.(api.VehicleOdometer); ok {
+		if odo, err := vs.Odometer(); err == nil {
+			lp.log.DEBUG.Printf("vehicle odometer: %.0fkm", odo)
+			lp.publish("vehicleOdometer", odo)
+		} else {
+			lp.log.ERROR.Printf("vehicle odometer: %v", err)
+		}
+	}
+}
+
 // updateChargerStatus updates charger status and detects car connected/disconnected events
 func (lp *LoadPoint) updateChargerStatus() error {
 	status, err := lp.charger.Status()
@@ -1418,8 +1440,24 @@ func (lp *LoadPoint) publishSoCAndRange() {
 	}
 }
 
+// addTask adds a single task to the queue
+func (lp *LoadPoint) addTask(task func()) {
+	lp.tasks.Enqueue(task)
+}
+
+// processTasks executes a single task from the queue
+func (lp *LoadPoint) processTasks() {
+	if lp.tasks != nil {
+		if task, ok := lp.tasks.Dequeue(); ok {
+			task.(func())()
+		}
+	}
+}
+
 // Update is the main control function. It reevaluates meters and charger state
 func (lp *LoadPoint) Update(sitePower float64, cheap, batteryBuffered bool) {
+	lp.processTasks()
+
 	mode := lp.GetMode()
 	lp.publish("mode", mode)
 
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index a2dada55..f59d335b 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -266,12 +266,15 @@ func (lp *LoadPoint) GetVehicles() []api.Vehicle {
 
 // SetVehicle sets the active vehicle
 func (lp *LoadPoint) SetVehicle(vehicle api.Vehicle) {
-	lp.Lock()
-	defer lp.Unlock()
+	// TODO develop universal locking approach
+	// setActiveVehicle is protected by lock, hence no locking here
 
 	// set desired vehicle
 	lp.setActiveVehicle(vehicle)
 
+	lp.Lock()
+	defer lp.Unlock()
+
 	// disable auto-detect
 	lp.stopVehicleDetection()
 }
diff --git a/go.mod b/go.mod
index 8dd52769..71a2dfbe 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
 	github.com/dustin/go-humanize v1.0.0
 	github.com/dylanmei/iso8601 v0.1.0
 	github.com/eclipse/paho.mqtt.golang v1.3.5
+	github.com/emirpasic/gods v1.18.1
 	github.com/evcc-io/eebus v0.0.0-20220628095038-be707d322cfc
 	github.com/fatih/structs v1.1.0
 	github.com/foogod/go-powerwall v0.2.0
diff --git a/go.sum b/go.sum
index 9eda5c6e..d299db26 100644
--- a/go.sum
+++ b/go.sum
@@ -216,6 +216,8 @@ github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFP
 github.com/eclipse/paho.mqtt.golang v1.3.5 h1:sWtmgNxYM9P2sP+xEItMozsR3w0cqZFlqnNN1bdl41Y=
 github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc=
 github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
diff --git a/templates/definition/charger/kse.yaml b/templates/definition/charger/kse.yaml
new file mode 100644
index 00000000..666fa4c9
--- /dev/null
+++ b/templates/definition/charger/kse.yaml
@@ -0,0 +1,17 @@
+template: kse
+products:
+  - brand: KSE
+    description:
+      generic: wBX16
+capabilities: ["1p3p", "rfid"]
+requirements:
+  evcc: ["sponsorship"]
+params:
+  - name: modbus
+    choice: ["rs485"]
+    baudrate: 9600
+    comset: 8E1
+    id: 100
+render: |
+  type: kse
+  {{- include "modbus" . }}
diff --git a/templates/docs/charger/kse_0.yaml b/templates/docs/charger/kse_0.yaml
new file mode 100644
index 00000000..ecfe3083
--- /dev/null
+++ b/templates/docs/charger/kse_0.yaml
@@ -0,0 +1,22 @@
+product:
+  brand: KSE
+  description: wBX16
+capabilities: ["1p3p", "rfid"]
+requirements: ["sponsorship"]
+render:
+  - default: |
+      type: template
+      template: kse      
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 100
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8E1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 100
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json
index d3e5fd40..9757e81e 100644
--- a/templates/evcc.io/brands.json
+++ b/templates/evcc.io/brands.json
@@ -21,6 +21,7 @@
   "Innogy",
   "Juice",
   "KEBA",
+  "KSE",
   "Mennekes",
   "NRGKick",
   "openWB",
diff --git a/vehicle/bmw/api.go b/vehicle/bmw/api.go
index 00583dec..71b2d230 100644
--- a/vehicle/bmw/api.go
+++ b/vehicle/bmw/api.go
@@ -29,7 +29,7 @@ type API struct {
 func NewAPI(log *util.Logger, brand string, identity oauth2.TokenSource) *API {
 	v := &API{
 		Helper:     request.NewHelper(log),
-		xUserAgent: fmt.Sprintf("android(SP1A.210812.016.C1);%s;2.5.2(14945)", brand),
+		xUserAgent: fmt.Sprintf("android(SP1A.210812.016.C1);%s;2.5.2(14945);row", brand),
 	}
 
 	// replace client transport with authenticated transport
@@ -43,7 +43,7 @@ func NewAPI(log *util.Logger, brand string, identity oauth2.TokenSource) *API {
 
 func (v *API) eadrax() (VehiclesStatusResponse, error) {
 	var res VehiclesStatusResponse
-	uri := fmt.Sprintf("%s/eadrax-vcs/v1/vehicles?apptimezone=60&appDateTime=%d", CocoApiURI, time.Now().Unix())
+	uri := fmt.Sprintf("%s/eadrax-vcs/v1/vehicles?apptimezone=120&appDateTime=%d", CocoApiURI, time.Now().Unix())
 
 	req, err := request.New(http.MethodGet, uri, nil, map[string]string{
 		"Content-Type":          request.JSONContent,


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 13c6ac57..e72a86cb 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -33,9 +33,7 @@ func (n *Null) ChargingTime() (time.Duration, error) {
 	return 0, nil
 }
 
-func attachListeners(t *testing.T, lp *LoadPoint) {
-	Voltage = 230 // V
-
+func createChannels(t *testing.T) (chan util.Param, chan push.Event, chan *LoadPoint) {
 	uiChan := make(chan util.Param)
 	pushChan := make(chan push.Event)
 	lpChan := make(chan *LoadPoint)
@@ -60,11 +58,24 @@ func attachListeners(t *testing.T, lp *LoadPoint) {
 		}
 	}()
 
+	return uiChan, pushChan, lpChan
+}
+
+func attachChannels(lp *LoadPoint, uiChan chan util.Param, pushChan chan push.Event, lpChan chan *LoadPoint) {
+	lp.uiChan = uiChan
+	lp.pushChan = pushChan
+	lp.lpChan = lpChan
+}
+
+func attachListeners(t *testing.T, lp *LoadPoint) {
+	Voltage = 230 // V
+
 	if charger, ok := lp.charger.(*mock.MockCharger); ok && charger != nil {
 		charger.EXPECT().Enabled().Return(true, nil)
 		charger.EXPECT().MaxCurrent(int64(lp.MinCurrent)).Return(nil)
 	}
 
+	uiChan, pushChan, lpChan := createChannels(t)
 	lp.Prepare(uiChan, pushChan, lpChan)
 }
 
@@ -287,7 +298,6 @@ func TestPVHysteresis(t *testing.T) {
 	}
 
 	for _, status := range []api.ChargeStatus{api.StatusB, api.StatusC} {
-
 		for _, tc := range tc {
 			t.Log(tc)
 
@@ -823,3 +833,115 @@ func TestVehicleDetectByID(t *testing.T) {
 		}
 	}
 }
+
+func TestDefaultVehicle(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	dflt := mock.NewMockVehicle(ctrl)
+	dflt.EXPECT().Title().Return("default").AnyTimes()
+	dflt.EXPECT().Capacity().AnyTimes()
+	dflt.EXPECT().Phases().AnyTimes()
+	dflt.EXPECT().OnIdentified().AnyTimes()
+
+	vehicle := mock.NewMockVehicle(ctrl)
+	vehicle.EXPECT().Title().Return("target").AnyTimes()
+	vehicle.EXPECT().Capacity().AnyTimes()
+	vehicle.EXPECT().Phases().AnyTimes()
+	vehicle.EXPECT().OnIdentified().AnyTimes()
+
+	lp := NewLoadPoint(util.NewLogger("foo"))
+	lp.defaultVehicle = dflt
+
+	// populate channels
+	x, y, z := createChannels(t)
+	attachChannels(lp, x, y, z)
+
+	title := func(v api.Vehicle) string {
+		if v == nil {
+			return "<nil>"
+		}
+		return v.Title()
+	}
+
+	// non-default vehicle identified
+	lp.setActiveVehicle(vehicle)
+	if lp.vehicle != vehicle {
+		t.Errorf("expected %v, got %v", title(vehicle), title(lp.vehicle))
+	}
+
+	// non-default vehicle disconnected
+	lp.evVehicleDisconnectHandler()
+	if lp.vehicle != dflt {
+		t.Errorf("expected %v, got %v", title(dflt), title(lp.vehicle))
+	}
+
+	// default vehicle disconnected
+	lp.evVehicleDisconnectHandler()
+	if lp.vehicle != dflt {
+		t.Errorf("expected %v, got %v", title(dflt), title(lp.vehicle))
+	}
+
+	// guest connected
+	lp.setActiveVehicle(nil)
+	if lp.vehicle != nil {
+		t.Errorf("expected %v, got %v", nil, title(lp.vehicle))
+	}
+}
+
+func TestApplyVehicleDefaults(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	newConfig := func(mode api.ChargeMode, minCurrent, maxCurrent float64, minSoC, targetSoC int) api.ActionConfig {
+		return api.ActionConfig{
+			Mode:       &mode,
+			MinCurrent: &minCurrent,
+			MaxCurrent: &maxCurrent,
+			MinSoC:     &minSoC,
+			TargetSoC:  &targetSoC,
+		}
+	}
+
+	assertConfig := func(lp *LoadPoint, conf api.ActionConfig) {
+		if lp.Mode != *conf.Mode {
+			t.Errorf("expected mode %v, got %v", *conf.Mode, lp.Mode)
+		}
+		if lp.MinCurrent != *conf.MinCurrent {
+			t.Errorf("expected minCurrent %v, got %v", *conf.MinCurrent, lp.MinCurrent)
+		}
+		if lp.MaxCurrent != *conf.MaxCurrent {
+			t.Errorf("expected maxCurrent %v, got %v", *conf.MaxCurrent, lp.MaxCurrent)
+		}
+		if lp.SoC.Min != *conf.MinSoC {
+			t.Errorf("expected minSoC %v, got %v", *conf.MinSoC, lp.SoC.Min)
+		}
+		if lp.SoC.Target != *conf.TargetSoC {
+			t.Errorf("expected targetSoC %v, got %v", *conf.TargetSoC, lp.SoC.Target)
+		}
+	}
+
+	oi := newConfig(api.ModePV, 7, 17, 1, 99)
+	od := newConfig(api.ModeOff, 5, 15, 2, 98)
+
+	vehicle := mock.NewMockVehicle(ctrl)
+	vehicle.EXPECT().Title().AnyTimes()
+	vehicle.EXPECT().Capacity().AnyTimes()
+	vehicle.EXPECT().OnIdentified().Return(oi)
+
+	lp := NewLoadPoint(util.NewLogger("foo"))
+
+	// populate channels
+	x, y, z := createChannels(t)
+	attachChannels(lp, x, y, z)
+
+	lp.onDisconnect = od
+	lp.ResetOnDisconnect = true
+
+	// vehicle identified
+	lp.setActiveVehicle(vehicle)
+	assertConfig(lp, oi)
+
+	// vehicle disconnected
+	vehicle.EXPECT().Phases().AnyTimes()
+	lp.evVehicleDisconnectHandler()
+	assertConfig(lp, od)
+}