Repository: evcc-io/evcc (go)

Issue:
BMW Climate function
**Is your feature request related to a problem? Please describe.**
I don't see my BMW i5 with a climate status in EVCC. As far as I know this should be possible?

**Describe the solution you'd like**
Add climate visibility/control of BMW newer models to EVCC

Pull request description:
BMW: add climater
This PR implements the api.VehicleClimater interface for BMW.

Fixes #17309.

Patch:
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 90042216..b2c3ada9 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -183,8 +183,8 @@ export default {
 		phases: Number,
 		phasesConfigured: Number,
 		phasesActive: Number,
-		chargerPhases1p3p: Boolean,
-		chargerPhysicalPhases: Number,
+		chargerPhaseSwitching: Boolean,
+		chargerPhaseReading: Boolean,
 		minCurrent: Number,
 		maxCurrent: Number,
 		chargeCurrent: Number,
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 2fd21aed..6b4506c8 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -47,7 +47,7 @@
 									{{ $t("main.loadpointSettings.phasesConfigured.label") }}
 								</label>
 								<div class="col-sm-8 pe-0">
-									<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
+									<p v-if="!chargerPhaseSwitching" class="mt-0 mb-2">
 										<small>
 											{{
 												$t(
@@ -184,8 +184,8 @@ export default {
 		id: [String, Number],
 		phasesConfigured: Number,
 		phasesActive: Number,
-		chargerPhases1p3p: Boolean,
-		chargerPhysicalPhases: Number,
+		chargerPhaseSwitching: Boolean,
+		chargerPhaseReading: Boolean,
 		batteryBoost: Boolean,
 		batteryBoostAvailable: Boolean,
 		mode: String,
@@ -215,14 +215,14 @@ export default {
 	},
 	computed: {
 		phasesOptions() {
-			if (this.chargerPhysicalPhases == 1) {
-				// known fixed phase configuration, no settings required
-				return [];
-			}
-			if (this.chargerPhases1p3p) {
+			if (this.chargerPhaseSwitching) {
 				// automatic switching
 				return [PHASES_AUTO, PHASES_3, PHASES_1];
 			}
+			if (this.chargerPhaseReading) {
+				// known fixed phase configuration, no settings required
+				return [];
+			}
 			// 1p or 3p possible
 			return [PHASES_3, PHASES_1];
 		},
@@ -230,7 +230,7 @@ export default {
 			return this.collectProps(LoadpointSettingsBatteryBoost);
 		},
 		maxPower() {
-			if (this.chargerPhases1p3p) {
+			if (this.chargerPhaseSwitching) {
 				if (this.phasesConfigured === PHASES_AUTO) {
 					return this.maxPowerPhases(3);
 				}
@@ -241,7 +241,7 @@ export default {
 			return this.fmtW(this.maxCurrent * V * this.phasesActive);
 		},
 		minPower() {
-			if (this.chargerPhases1p3p) {
+			if (this.chargerPhaseSwitching) {
 				if (this.phasesConfigured === PHASES_AUTO) {
 					return this.minPowerPhases(1);
 				}
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 4308fddf..f9966df8 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -22,8 +22,8 @@ const (
 
 	ChargerIcon           = "chargerIcon"           // charger icon for ui
 	ChargerFeature        = "chargerFeature"        // charger feature
-	ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases
-	ChargerPhases1p3p     = "chargerPhases1p3p"     // phase switcher (1p3p chargers)
+	ChargerPhaseSwitching = "chargerPhaseSwitching" // api.PhaseSwitcher: phase switcher (1p3p chargers) available
+	ChargerPhaseReading   = "chargerPhaseReading"   // api.PhaseDescriber: get charger phases available
 	ChargerStatusReason   = "chargerStatusReason"   // either awaiting authorization or disconnect required
 
 	// loadpoint status
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 906a4c39..04de9969 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -624,18 +624,13 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish(keys.DisableDelay, lp.Disable.Delay)
 
 	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
-	lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
+	lp.publish(keys.ChargerPhaseSwitching, lp.hasPhaseSwitching())
+	lp.publish(keys.ChargerPhaseReading, lp.getChargerPhysicalPhases() != 0)
 	lp.publish(keys.PhasesEnabled, lp.phases)
 	lp.publish(keys.PhasesActive, lp.ActivePhases())
 	lp.publishTimer(phaseTimer, 0, timerInactive)
 	lp.publishTimer(pvTimer, 0, timerInactive)
 
-	if phases := lp.getChargerPhysicalPhases(); phases != 0 {
-		lp.publish(keys.ChargerPhysicalPhases, phases)
-	} else {
-		lp.publish(keys.ChargerPhysicalPhases, nil)
-	}
-
 	// charger features
 	for _, f := range []api.Feature{api.IntegratedDevice, api.Heating} {
 		lp.publishChargerFeature(f)
diff --git a/meter/zendure/connection.go b/meter/zendure/connection.go
index da819469..f1c7de9f 100644
--- a/meter/zendure/connection.go
+++ b/meter/zendure/connection.go
@@ -73,12 +73,10 @@ func (c *Connection) handler(data string) {
 	}
 
 	c.data.SetFunc(func(v Data) Data {
-		if err := mergo.Merge(&v, res.Data); err != nil {
+		if err := mergo.Merge(&v, res.Data, mergo.WithOverride); err != nil {
 			c.log.ERROR.Println(err)
 		}
 
-		c.log.TRACE.Printf("!! data: %+v", v)
-
 		return v
 	})
 }
diff --git a/vehicle/bmw/provider.go b/vehicle/bmw/provider.go
index 6978347b..3c65beee 100644
--- a/vehicle/bmw/provider.go
+++ b/vehicle/bmw/provider.go
@@ -108,6 +108,14 @@ func (v *Provider) GetLimitSoc() (int64, error) {
 	return res.State.ElectricChargingState.ChargingTarget, nil
 }
 
+var _ api.VehicleClimater = (*Provider)(nil)
+
+// Climater implements the api.VehicleClimater interface
+func (v *Provider) Climater() (bool, error) {
+	res, err := v.statusG()
+	return res.State.ClimateControlState.Activity == "HEATING" || res.State.ClimateControlState.Activity == "COOLING", err
+}
+
 var _ api.Resurrector = (*Provider)(nil)
 
 func (v *Provider) WakeUp() error {
diff --git a/vehicle/bmw/types.go b/vehicle/bmw/types.go
index 6d2f7aa7..5f8f07ec 100644
--- a/vehicle/bmw/types.go
+++ b/vehicle/bmw/types.go
@@ -19,5 +19,8 @@ type VehicleStatus struct {
 			ChargingStatus       string
 			ChargingTarget       int64
 		}
+		ClimateControlState struct {
+			Activity string
+		}
 	}
 }


Test patch:
diff --git a/meter/zendure/connection_test.go b/meter/zendure/connection_test.go
new file mode 100644
index 00000000..12eb9bfb
--- /dev/null
+++ b/meter/zendure/connection_test.go
@@ -0,0 +1,31 @@
+package zendure
+
+import (
+	"testing"
+	"time"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHandler(t *testing.T) {
+	conn := &Connection{
+		log:  util.NewLogger("test"),
+		data: util.NewMonitor[Data](time.Minute),
+	}
+
+	{
+		conn.handler(`{"solarInputPower":113,"sn":"serial"}`)
+		res, err := conn.data.Get()
+		assert.NoError(t, err)
+
+		assert.Equal(t, Data{SolarInputPower: 113, Sn: "serial"}, res)
+	}
+	{
+		conn.handler(`{"solarInputPower":125,"sn":"serial"}`)
+		res, err := conn.data.Get()
+		assert.NoError(t, err)
+
+		assert.Equal(t, Data{SolarInputPower: 125, Sn: "serial"}, res)
+	}
+}