diff --git a/api/api.go b/api/api.go index 7e3df35ea..6a4d85796 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/api/mock.go b/api/mock.go index 8c7270832..69a741239 100644 --- a/api/mock.go +++ b/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 diff --git a/charger/phoenix-charx.go b/charger/phoenix-charx.go index 8f64147ac..46801a3df 100644 --- a/charger/phoenix-charx.go +++ b/charger/phoenix-charx.go @@ -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 diff --git a/core/loadpoint.go b/core/loadpoint.go index ecd9f83d2..78f9b333a 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 66b2c5db5..a72d647c6 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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) +} diff --git a/templates/definition/charger/phoenix-charx.yaml b/templates/definition/charger/phoenix-charx.yaml index ffe63d7a7..9bea8cbe8 100644 --- a/templates/definition/charger/phoenix-charx.yaml +++ b/templates/definition/charger/phoenix-charx.yaml @@ -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"]