From a7170a2d3e0ea05939c41843e48d105dec567614 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 May 2026 14:29:02 +0200 Subject: [PATCH] E3DC: reconnect on RSCP error (#30257) --- charger/e3dc.go | 108 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 21 deletions(-) diff --git a/charger/e3dc.go b/charger/e3dc.go index 06efcb27f..bc30993ab 100644 --- a/charger/e3dc.go +++ b/charger/e3dc.go @@ -54,9 +54,11 @@ import ( // E3dc charger implementation using RSCP protocol. // Communicates with the E3DC Hauskraftwerk via TCP connection. type E3dc struct { - log *util.Logger // Logger instance for debug/warning output - conn *rscp.Client // RSCP client connection to E3DC system - id uint8 // Wallbox index (0 = first wallbox, 1 = second, etc.) + mu sync.Mutex // serializes RSCP exchanges; rscp.Client is not reentrant + log *util.Logger // Logger instance for debug/warning output + conn *rscp.Client // RSCP client connection to E3DC system + id uint8 // Wallbox index (0 = first wallbox, 1 = second, etc.) + retry func() error // recreates the RSCP client on connection errors } func init() { @@ -142,6 +144,12 @@ func NewE3dc(ctx context.Context, cfg rscp.ClientConfig, id uint8) (*E3dc, error id: id, } + wb.retry = func() (err error) { + wb.conn.Disconnect() + wb.conn, err = rscp.NewClient(cfg) + return err + } + // Check wallbox configuration and warn if not optimal for evcc control if err := wb.checkConfiguration(); err != nil { return nil, err @@ -150,9 +158,25 @@ func NewE3dc(ctx context.Context, cfg rscp.ClientConfig, id uint8) (*E3dc, error return wb, nil } +// retrySend executes a single message request with one retry on error. +// The E3DC Hauskraftwerk occasionally drops the RSCP socket (manifests as +// "authentication error: EOF"). A fresh client recovers from this state. +func (wb *E3dc) retrySend(msg rscp.Message) (*rscp.Message, error) { + res, err := wb.conn.Send(msg) + if err == nil { + return res, nil + } + + if err := wb.retry(); err != nil { + return nil, err + } + + return wb.conn.Send(msg) +} + // checkConfiguration verifies wallbox settings and adjusts them for evcc control func (wb *E3dc) checkConfiguration() error { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil), *rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil), @@ -191,7 +215,7 @@ func (wb *E3dc) checkConfiguration() error { // disableSunMode sends the command to disable sun mode func (wb *E3dc) disableSunMode() { - if _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + if _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_SUN_MODE_ACTIVE, false), })); err != nil { @@ -203,7 +227,7 @@ func (wb *E3dc) disableSunMode() { // Called before control commands (Enable, MaxCurrent) because the user could // re-enable sun mode in the E3DC portal at any time without restarting evcc. func (wb *E3dc) ensureSunModeDisabled() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SUN_MODE_ACTIVE, nil), })) @@ -226,7 +250,7 @@ func (wb *E3dc) ensureSunModeDisabled() { func (wb *E3dc) disableAutoPhaseSwitch() { // Note: WB_REQ_SET_AUTO_PHASE_SWITCH_ENABLED has wrong DataType in go-rscp (None instead of Bool) // We must create the message with explicit DataType - if _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + if _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), {Tag: rscp.WB_REQ_SET_AUTO_PHASE_SWITCH_ENABLED, DataType: rscp.Bool, Value: false}, })); err != nil { @@ -238,7 +262,7 @@ func (wb *E3dc) disableAutoPhaseSwitch() { // Called before phase switch commands because the user could re-enable it // in the E3DC portal at any time without restarting evcc. func (wb *E3dc) ensureAutoPhaseDisabled() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_AUTO_PHASE_SWITCH_ENABLED, nil), })) @@ -269,7 +293,7 @@ func (wb *E3dc) ensureAutoPhaseDisabled() { // Used by Status() and Enabled() to determine charging state. func (wb *E3dc) getExternDataAlg() ([]byte, error) { // RSCP request pattern: WB_REQ_DATA container with WB_INDEX + request tags - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_EXTERN_DATA_ALG, nil), })) @@ -303,6 +327,9 @@ func (wb *E3dc) getExternDataAlg() ([]byte, error) { // Enabled implements the api.Charger interface func (wb *E3dc) Enabled() (bool, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + b, err := wb.getExternDataAlg() if err != nil { return false, err @@ -315,9 +342,12 @@ func (wb *E3dc) Enabled() (bool, error) { // Enable implements the api.Charger interface // Controls charging by setting the abort flag (inverted logic: abort=false means enabled) func (wb *E3dc) Enable(enable bool) error { + wb.mu.Lock() + defer wb.mu.Unlock() + wb.ensureSunModeDisabled() - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_ABORT_CHARGING, !enable), })) @@ -328,6 +358,9 @@ func (wb *E3dc) Enable(enable bool) error { // Status implements the api.Charger interface // Returns the charging state by reading status flags from WB_EXTERN_DATA_ALG func (wb *E3dc) Status() (api.ChargeStatus, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + b, err := wb.getExternDataAlg() if err != nil { return api.StatusNone, err @@ -356,9 +389,12 @@ func (wb *E3dc) Status() (api.ChargeStatus, error) { // MaxCurrent implements the api.Charger interface. // Sets the maximum charging current in Ampere (whole numbers only, 6-32A typical range). func (wb *E3dc) MaxCurrent(current int64) error { + wb.mu.Lock() + defer wb.mu.Unlock() + wb.ensureSunModeDisabled() - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_MAX_CHARGE_CURRENT, uint8(current)), })) @@ -371,6 +407,9 @@ var _ api.Meter = (*E3dc)(nil) // CurrentPower implements the api.Meter interface // Returns the total charging power by summing all three phases func (wb *E3dc) CurrentPower() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + p1, p2, p3, err := wb.powers() if err != nil { return 0, err @@ -390,8 +429,11 @@ var _ api.MeterEnergy = (*E3dc)(nil) // The sum of both values matches the total energy shown in the E3DC portal. // Testing showed: DB_TEC (8319 kWh) + WB_ENERGY (699 kWh) = 9018 kWh ≈ Portal (9019 kWh) func (wb *E3dc) TotalEnergy() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + // Query both energy sources sequentially - res, err := wb.conn.Send(*rscp.NewMessage(rscp.DB_REQ_TEC_WALLBOX_VALUES, nil)) + res, err := wb.retrySend(*rscp.NewMessage(rscp.DB_REQ_TEC_WALLBOX_VALUES, nil)) if err != nil { return 0, err } @@ -436,7 +478,7 @@ func (wb *E3dc) TotalEnergy() (float64, error) { } // Query WB_ENERGY_ALL for energy since last DB sync - res, err = wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err = wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_ENERGY_ALL, nil), })) @@ -462,7 +504,7 @@ func (wb *E3dc) TotalEnergy() (float64, error) { // Used internally by CurrentPower() and Currents(). // Returns (L1, L2, L3) power values - unused phases return 0. func (wb *E3dc) powers() (float64, float64, float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_PM_POWER_L1, nil), *rscp.NewMessage(rscp.WB_REQ_PM_POWER_L2, nil), @@ -523,7 +565,10 @@ var _ api.PhaseGetter = (*E3dc)(nil) // Returns the configured number of phases (1 or 3) // Note: WB_PM_ACTIVE_PHASES reports physical wiring, WB_NUMBER_PHASES reports actual configuration func (wb *E3dc) GetPhases() (int, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_NUMBER_PHASES, nil), })) @@ -549,7 +594,10 @@ var _ api.CurrentLimiter = (*E3dc)(nil) // GetMinMaxCurrent implements the api.CurrentLimiter interface // Returns the wallbox's hardware current limits (typically 6-32A) func (wb *E3dc) GetMinMaxCurrent() (float64, float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_LOWER_CURRENT_LIMIT, nil), *rscp.NewMessage(rscp.WB_REQ_UPPER_CURRENT_LIMIT, nil), @@ -581,7 +629,10 @@ var _ api.CurrentGetter = (*E3dc)(nil) // GetMaxCurrent implements the api.CurrentGetter interface // Returns the currently configured maximum charging current func (wb *E3dc) GetMaxCurrent() (float64, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_MAX_CHARGE_CURRENT, nil), })) @@ -606,7 +657,7 @@ func (wb *E3dc) GetMaxCurrent() (float64, error) { // Returns all session-related messages (energy, time, RFID, etc.). // If no vehicle is connected, returns only WB_INDEX with no session data. func (wb *E3dc) getSessionData() ([]rscp.Message, error) { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_SESSION, nil)) + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_SESSION, nil)) if err != nil { return nil, err } @@ -638,6 +689,9 @@ var _ api.ChargeRater = (*E3dc)(nil) // ChargedEnergy implements the api.ChargeRater interface // Returns the energy charged in the current session from WB_SESSION_CHARGED_ENERGY func (wb *E3dc) ChargedEnergy() (float64, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_CHARGED_ENERGY) if err != nil || !found { return 0, err @@ -656,6 +710,9 @@ var _ api.ChargeTimer = (*E3dc)(nil) // ChargeDuration implements the api.ChargeTimer interface // Returns the active charging duration from WB_SESSION_ACTIVE_CHARGE_TIME func (wb *E3dc) ChargeDuration() (time.Duration, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_ACTIVE_CHARGE_TIME) if err != nil || !found { return 0, err @@ -675,6 +732,9 @@ var _ api.Identifier = (*E3dc)(nil) // Identify implements the api.Identifier interface // Returns the RFID tag ID from WB_SESSION_AUTH_DATA if a session is active func (wb *E3dc) Identify() (string, error) { + wb.mu.Lock() + defer wb.mu.Unlock() + msg, found, err := wb.sessionMessage(rscp.WB_SESSION_AUTH_DATA) if err != nil || !found { return "", err @@ -689,6 +749,9 @@ var _ api.PhaseSwitcher = (*E3dc)(nil) // Switches between 1-phase and 3-phase charging // The wallbox handles the safe switching sequence internally (reduce current, switch, ramp up) func (wb *E3dc) Phases1p3p(phases int) error { + wb.mu.Lock() + defer wb.mu.Unlock() + if phases != 1 && phases != 3 { return fmt.Errorf("invalid phases: %d (must be 1 or 3)", phases) } @@ -697,7 +760,7 @@ func (wb *E3dc) Phases1p3p(phases int) error { wb.ensureAutoPhaseDisabled() // Perform phase switch - _, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + _, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_SET_NUMBER_PHASES, uint8(phases)), })) @@ -711,7 +774,10 @@ var _ api.Diagnosis = (*E3dc)(nil) // Outputs wallbox information for debugging via evcc's "evcc charger" command. // Shows device name, firmware, current limits, phase config, and status flags. func (wb *E3dc) Diagnose() { - res, err := wb.conn.Send(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ + wb.mu.Lock() + defer wb.mu.Unlock() + + res, err := wb.retrySend(*rscp.NewMessage(rscp.WB_REQ_DATA, []rscp.Message{ *rscp.NewMessage(rscp.WB_INDEX, wb.id), *rscp.NewMessage(rscp.WB_REQ_DEVICE_NAME, nil), *rscp.NewMessage(rscp.WB_REQ_FIRMWARE_VERSION, nil), @@ -786,7 +852,7 @@ func (wb *E3dc) Diagnose() { // RSCP messages contain typed values that need to be extracted and validated. // // Typical usage pattern: -// 1. Send request via wb.conn.Send() +// 1. Send request via wb.retrySend() // 2. Parse response container via rscpContainer() // 3. Extract typed values via rscpFloat64(), rscpBool(), rscpString(), etc. // ===========================================================================