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

@ -7,7 +7,7 @@ import (
"time"
)
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
// Meter provides total active power in W
type Meter interface {
@ -115,6 +115,11 @@ type ChargeTimer interface {
ChargeDuration() (time.Duration, error)
}
// ConnectionTimer provides current connection duration
type ConnectionTimer interface {
ConnectionDuration() (time.Duration, error)
}
// ChargeRater provides charged energy amount in kWh
type ChargeRater interface {
ChargedEnergy() (float64, error)

View file

@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff)
//
// Generated by this command:
//
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Tariff
//
// Package api is a generated GoMock package.
@ -11,6 +11,7 @@ package api
import (
reflect "reflect"
time "time"
gomock "go.uber.org/mock/gomock"
)
@ -637,6 +638,45 @@ func (mr *MockVehicleMockRecorder) Soc() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Soc", reflect.TypeOf((*MockVehicle)(nil).Soc))
}
// MockConnectionTimer is a mock of ConnectionTimer interface.
type MockConnectionTimer struct {
ctrl *gomock.Controller
recorder *MockConnectionTimerMockRecorder
isgomock struct{}
}
// MockConnectionTimerMockRecorder is the mock recorder for MockConnectionTimer.
type MockConnectionTimerMockRecorder struct {
mock *MockConnectionTimer
}
// NewMockConnectionTimer creates a new mock instance.
func NewMockConnectionTimer(ctrl *gomock.Controller) *MockConnectionTimer {
mock := &MockConnectionTimer{ctrl: ctrl}
mock.recorder = &MockConnectionTimerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockConnectionTimer) EXPECT() *MockConnectionTimerMockRecorder {
return m.recorder
}
// ConnectionDuration mocks base method.
func (m *MockConnectionTimer) ConnectionDuration() (time.Duration, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ConnectionDuration")
ret0, _ := ret[0].(time.Duration)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ConnectionDuration indicates an expected call of ConnectionDuration.
func (mr *MockConnectionTimerMockRecorder) ConnectionDuration() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectionDuration", reflect.TypeOf((*MockConnectionTimer)(nil).ConnectionDuration))
}
// MockChargeRater is a mock of ChargeRater interface.
type MockChargeRater struct {
ctrl *gomock.Controller

View file

@ -20,19 +20,20 @@ const (
// per-unit registers
charxOffset = 1000
charxRegMeter = 112
charxRegVoltages = 232 // mV
charxRegCurrents = 238 // mA
charxRegPower = 244 // mW
charxRegEnergy = 250 // Wh
charxRegSoc = 264 // %
charxRegEvid = 265 // 10
charxRegRfid = 275 // 10
charxRegChargeTime = 287 // s
charxRegChargeEnergy = 289 // Wh
charxRegStatus = 299 // IEC 61851-1
charxRegEnable = 300
charxRegMaxCurrent = 301 // A
charxRegMeter = 112
charxRegVoltages = 232 // mV
charxRegCurrents = 238 // mA
charxRegPower = 244 // mW
charxRegEnergy = 250 // Wh
charxRegSoc = 264 // %
charxRegEvid = 265 // 10
charxRegRfid = 275 // 10
charxRegConnectionTime = 285 // s
charxRegChargeTime = 287 // s
charxRegChargeEnergy = 289 // Wh
charxRegStatus = 299 // IEC 61851-1
charxRegEnable = 300
charxRegMaxCurrent = 301 // A
)
// PhoenixCharx is an api.Charger implementation for Phoenix CHARX controller
@ -199,7 +200,19 @@ func (wb *PhoenixCharx) ChargeDuration() (time.Duration, error) {
return 0, err
}
return time.Duration(encoding.Uint16(b)) * time.Second, nil
return time.Duration(encoding.Uint32(b)) * time.Second, nil
}
var _ api.ConnectionTimer = (*PhoenixCharx)(nil)
// ConnectionDuration implements the api.ConnectionTimer interface
func (wb *PhoenixCharx) ConnectionDuration() (time.Duration, error) {
b, err := wb.conn.ReadHoldingRegisters(wb.register(charxRegConnectionTime), 2)
if err != nil {
return 0, err
}
return time.Duration(encoding.Uint32(b)) * time.Second, nil
}
// currentPower implements the api.Meter interface

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)
}

View file

@ -9,6 +9,15 @@ products:
- brand: LadeFoxx
description:
generic: Mikro 2.0
- brand: Veton
description:
generic: One
- brand: Veton
description:
generic: Two
- brand: Veton
description:
generic: Wall
params:
- name: modbus
choice: ["tcpip"]