Loadpoint: detect connection changes using connected duration (#24730)

This commit is contained in:
Sam Feyaerts 2025-10-31 11:57:00 +01:00 • committed by GitHub
parent d818bf1467
commit c023b080d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 201 additions and 50 deletions

View file

@ -163,6 +163,7 @@ type Loadpoint struct {
// charge progress
vehicleSoc float64 // Vehicle or charger soc
chargeDuration time.Duration // Charge duration
connectedDuration time.Duration // Connection duration
energyMetrics EnergyMetrics // Stats for charged energy by session
chargeRemainingDuration time.Duration // Remaining charge duration
chargeRemainingEnergy float64 // Remaining charge energy in kWh
@ -1073,51 +1074,88 @@ func statusEvents(prevStatus, status api.ChargeStatus) []string {
// updateChargerStatus updates charger status and detects car connected/disconnected events
func (lp *Loadpoint) updateChargerStatus() (bool, error) {
var welcomeCharge bool
status, err := lp.charger.Status()
if err != nil {
return false, fmt.Errorf("charger status: %w", err)
statusChanges, err := lp.getStatusChanges()
if err != nil || len(statusChanges) == 0 {
return false, err
}
lp.log.DEBUG.Printf("charger status: %s", status)
if prevStatus := lp.GetStatus(); status != prevStatus {
for _, status := range statusChanges {
prevStatus := lp.GetStatus()
lp.setStatus(status)
for _, ev := range statusEvents(prevStatus, status) {
lp.bus.Publish(ev)
// send connect/disconnect events except during startup
if prevStatus != api.StatusNone {
switch ev {
case evVehicleConnect:
welcomeCharge = lp.chargerHasFeature(api.WelcomeCharge) || hasFeature(lp.defaultVehicle, api.WelcomeCharge)
// Enable charging on connect if any available vehicle requires it.
// We're using the PV timer to disable after the welcome
if !welcomeCharge && !lp.chargerHasFeature(api.IntegratedDevice) {
for _, v := range lp.availableVehicles() {
if slices.Contains(v.Features(), api.WelcomeCharge) {
welcomeCharge = true
lp.log.DEBUG.Printf("welcome charge: %s", v.GetTitle())
break
}
}
}
lp.pushEvent(evVehicleConnect)
case evVehicleDisconnect:
lp.pushEvent(evVehicleDisconnect)
}
if prevStatus != api.StatusNone && (ev == evVehicleConnect || ev == evVehicleDisconnect) {
lp.pushEvent(ev)
}
}
// update whenever there is a state change
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
}
return welcomeCharge, nil
// update whenever there is a state change
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
return lp.needsWelcomeCharge(), nil
}
// getStatusChanges checks charger status and returns a chronological list of status changes
func (lp *Loadpoint) getStatusChanges() ([]api.ChargeStatus, error) {
var res []api.ChargeStatus
status, err := lp.charger.Status()
if err != nil {
return nil, fmt.Errorf("charger status: %w", err)
}
lp.log.DEBUG.Printf("charger status: %s", status)
// detect if charger status changed
if prevStatus := lp.GetStatus(); status != prevStatus {
res = append(res, status)
}
// check charger connection duration
if ct, ok := lp.charger.(api.ConnectionTimer); ok {
d, err := ct.ConnectionDuration()
if err != nil {
return nil, fmt.Errorf("connection duration: %w", err)
}
defer func() { lp.connectedDuration = d }()
// connection duration dropped while status unchanged, indicates intermediate disconnect
if len(res) == 0 && d < lp.connectedDuration {
lp.log.DEBUG.Printf("connection duration drop detected (%s -> %v)", lp.connectedDuration.Round(time.Second), d.Round(time.Second))
res = append(res, api.StatusA, status)
}
}
return res, nil
}
// needsWelcomeCharge checks if either the charger or a vehicle requires a welcome charge
func (lp *Loadpoint) needsWelcomeCharge() bool {
if !lp.connected() {
return false
}
if lp.chargerHasFeature(api.WelcomeCharge) || hasFeature(lp.defaultVehicle, api.WelcomeCharge) {
return true
}
// Enable charging on connect if any available vehicle requires it.
// We're using the PV timer to disable after the welcome
if !lp.chargerHasFeature(api.IntegratedDevice) {
for _, v := range lp.availableVehicles() {
if slices.Contains(v.Features(), api.WelcomeCharge) {
lp.log.DEBUG.Printf("welcome charge: %s", v.GetTitle())
return true
}
}
}
return false
}
// effectiveCurrent returns the currently effective charging current

View file

@ -764,3 +764,49 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
ctrl.Finish()
}
}
func TestConnectionDurationDropDetection(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
ch := api.NewMockCharger(ctrl)
ct := api.NewMockConnectionTimer(ctrl)
charger := struct {
api.Charger
api.ConnectionTimer
}{
ch, ct,
}
ch.EXPECT().Status().Return(api.StatusC, nil)
ch.EXPECT().Enabled().AnyTimes().Return(true, nil)
ch.EXPECT().MaxCurrent(int64(minA)).Return(nil)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
charger: charger,
minCurrent: minA,
maxCurrent: maxA,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(), // silence nil panics
}
attachListeners(t, lp)
connectedTime := clock.Now().Add(-10 * time.Minute)
lp.enabled = true
lp.status = api.StatusC
lp.connectedDuration = 10 * time.Minute
lp.connectedTime = connectedTime
ct.EXPECT().ConnectionDuration().Return(0*time.Second, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
assert.NotEqual(t, connectedTime, lp.connectedTime)
}