Loadpoint: detect connection changes using connected duration (attempt 2) (#24957)
This commit is contained in:
parent
0bcca69b8e
commit
3a24dd0948
6 changed files with 251 additions and 41 deletions
|
|
@ -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)
|
||||
|
|
|
|||
44
api/mock.go
44
api/mock.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,16 +1074,15 @@ 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)
|
||||
var welcomeCharge bool
|
||||
|
||||
if prevStatus := lp.GetStatus(); status != prevStatus {
|
||||
for _, status := range statusChanges {
|
||||
prevStatus := lp.GetStatus()
|
||||
lp.setStatus(status)
|
||||
|
||||
for _, ev := range statusEvents(prevStatus, status) {
|
||||
|
|
@ -1092,34 +1092,78 @@ func (lp *Loadpoint) updateChargerStatus() (bool, error) {
|
|||
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)
|
||||
welcomeCharge = lp.needsWelcomeCharge()
|
||||
case evVehicleDisconnect:
|
||||
lp.pushEvent(evVehicleDisconnect)
|
||||
welcomeCharge = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update whenever there is a state change
|
||||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||||
}
|
||||
|
||||
// update whenever there is a state change
|
||||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||||
|
||||
return welcomeCharge, 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
|
||||
prevStatus := lp.GetStatus()
|
||||
if status != prevStatus {
|
||||
res = []api.ChargeStatus{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 without disconnect status, indicates intermediate disconnect
|
||||
if status != api.StatusA && prevStatus != api.StatusA && d < lp.connectedDuration {
|
||||
lp.log.DEBUG.Printf("connection duration drop detected (%s -> %v)", lp.connectedDuration.Round(time.Second), d.Round(time.Second))
|
||||
res = []api.ChargeStatus{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.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
|
||||
func (lp *Loadpoint) effectiveCurrent() float64 {
|
||||
if !lp.charging() {
|
||||
|
|
|
|||
|
|
@ -764,3 +764,102 @@ 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.StatusB, 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)
|
||||
}
|
||||
|
||||
func TestWelcomeChargeAppliedOnlyOnce(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
ctrl := gomock.NewController(t)
|
||||
ch := api.NewMockCharger(ctrl)
|
||||
fd := api.NewMockFeatureDescriber(ctrl)
|
||||
|
||||
charger := struct {
|
||||
api.Charger
|
||||
api.FeatureDescriber
|
||||
}{
|
||||
ch, fd,
|
||||
}
|
||||
|
||||
ch.EXPECT().Enabled().AnyTimes().Return(true, nil)
|
||||
ch.EXPECT().MaxCurrent(int64(minA)).Return(nil)
|
||||
fd.EXPECT().Features().AnyTimes().Return([]api.Feature{
|
||||
api.WelcomeCharge,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
lp.enabled = true
|
||||
lp.status = api.StatusA
|
||||
|
||||
// No welcome charge when not connected
|
||||
ch.EXPECT().Status().Return(api.StatusA, nil)
|
||||
welcomeCharge, _ := lp.updateChargerStatus()
|
||||
assert.False(t, welcomeCharge)
|
||||
|
||||
// Welcome charge when connected
|
||||
ch.EXPECT().Status().Return(api.StatusC, nil)
|
||||
welcomeCharge, _ = lp.updateChargerStatus()
|
||||
assert.True(t, welcomeCharge)
|
||||
|
||||
// No welcome charge when still connected
|
||||
ch.EXPECT().Status().Return(api.StatusB, nil)
|
||||
welcomeCharge, _ = lp.updateChargerStatus()
|
||||
assert.False(t, welcomeCharge)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue