diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 4950c774cb3..e01e89a0e6c 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -162,7 +162,14 @@ func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) { // re-publish vehicle settings lp.publish(keys.PhasesActive, lp.ActivePhases()) - lp.unpublishVehicle() + + // only reset published vehicle data when the active vehicle actually changed. + // vehicleDefaultOrDetect re-assigns the same default vehicle on every reconnect + // to refresh the soc estimator (#27359); zeroing a still-valid soc there makes + // vehicleSoc/vehicleRange flap to 0 while charging until the next soc read. + if from != to { + lp.unpublishVehicle() + } // publish effective values lp.PublishEffectiveValues() diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index e3bebee2747..37bd5c53aa4 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -323,6 +323,44 @@ func TestDefaultVehicle(t *testing.T) { assert.Nil(t, lp.vehicle, "expected no vehicle") } +// TestReassignActiveVehicleKeepsSoc is a regression test for vehicleSoc dropping to +// 0 while charging. vehicleDefaultOrDetect re-assigns the already-active default +// vehicle on every (re)connect so the soc estimator is refreshed for the new session +// (#27359, #27364). setActiveVehicle unconditionally calls unpublishVehicle, which +// zeroes vehicleSoc and publishes 0. When the active vehicle does not actually +// change, a known soc must be preserved - otherwise /api/state and messaging show 0 +// until the next successful soc read, which manifests as the soc flapping to 0 while +// charging after an evcc restart mid-session. +func TestReassignActiveVehicleKeepsSoc(t *testing.T) { + ctrl := gomock.NewController(t) + + v := api.NewMockVehicle(ctrl) + v.EXPECT().GetTitle().Return("default").AnyTimes() + v.EXPECT().Icon().Return("").AnyTimes() + v.EXPECT().Capacity().AnyTimes() + v.EXPECT().Phases().AnyTimes() + v.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes() + + lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo")) + lp.defaultVehicle = v + + x, y, z := createChannels(t) + attachChannels(lp, x, y, z) + + // vehicle becomes active and a soc has been read and published + lp.setActiveVehicle(v) + lp.vehicleSoc = 71 + + // same default vehicle re-assigned (e.g. vehicleDefaultOrDetect on reconnect) + lp.setActiveVehicle(v) + assert.Equal(t, v, lp.vehicle, "expected same vehicle to stay active") + assert.Equal(t, 71.0, lp.vehicleSoc, "soc must be preserved when the active vehicle is unchanged") + + // switching the active vehicle must still clear the stale soc + lp.setActiveVehicle(nil) + assert.Zero(t, lp.vehicleSoc, "soc must be cleared when the vehicle changes") +} + // integratedDeviceCharger is a minimal charger advertising the IntegratedDevice feature. type integratedDeviceCharger struct{}