From 86e201453259f5b4472f8e4b8de4fa51262fa465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Mon, 9 Mar 2026 12:09:52 +0100 Subject: [PATCH] ChargeX: add heartbeat to prevent PAC_Target_Timeout fallback (#27795) (#28059) --- charger/chargex.go | 51 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/charger/chargex.go b/charger/chargex.go index da32f7809..9e5ac87a4 100644 --- a/charger/chargex.go +++ b/charger/chargex.go @@ -21,6 +21,8 @@ import ( "context" "encoding/binary" "fmt" + "sync" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" @@ -35,7 +37,9 @@ type ChargeX struct { log *util.Logger conn *modbus.Connection connector uint16 + mu sync.Mutex curr float64 + enabled bool } const ( @@ -48,8 +52,9 @@ const ( chargexRegModuleState = 8 // States_CP_X offset // Holding registers (read/write) - chargexRegTargetPower = 504 // 0x01F8 PAC_Target_Power (W) - U32 - chargexRegChargingMode = 506 // 0x01FA Charging_Mode (0=Full, 1=Min, 2=NoRed) - U32 + chargexRegTargetTimeout = 500 // 0x01F4 PAC_Target_Timeout (s) - U32 + chargexRegTargetPower = 504 // 0x01F8 PAC_Target_Power (W) - U32 + chargexRegChargingMode = 506 // 0x01FA Charging_Mode (0=Full, 1=Min, 2=NoRed) - U32 ) func init() { @@ -103,9 +108,39 @@ func NewChargeX(ctx context.Context, uri string, id uint8, connector uint16) (ap return nil, fmt.Errorf("failed to initialize charging mode: %w", err) } + // Read target timeout and start heartbeat to keep PAC_Target_Power fresh. + // Without periodic updates the charger reverts to PAC_Default_Power after + // the configured timeout (default 20 min, per Aqueduct Modbus spec ยง3.6.1). + b, err = conn.ReadHoldingRegisters(chargexRegTargetTimeout, 2) + if err != nil { + return nil, fmt.Errorf("target timeout: %w", err) + } + if u := binary.BigEndian.Uint32(b); u > 0 { + go wb.heartbeat(ctx, time.Duration(u)*time.Second/2) + } + return wb, nil } +func (wb *ChargeX) heartbeat(ctx context.Context, timeout time.Duration) { + for tick := time.Tick(timeout); ; { + select { + case <-tick: + case <-ctx.Done(): + return + } + wb.mu.Lock() + var curr float64 + if wb.enabled { + curr = wb.curr + } + wb.mu.Unlock() + if err := wb.setCurrent(curr); err != nil { + wb.log.ERROR.Println("heartbeat:", err) + } + } +} + // moduleReg returns the register address for a module-specific register func (wb *ChargeX) moduleReg(offset uint16) uint16 { // connector is 1-indexed, convert to 0-indexed module_index @@ -195,12 +230,19 @@ func (wb *ChargeX) Enabled() (bool, error) { // Enable implements the api.Charger interface func (wb *ChargeX) Enable(enable bool) error { + wb.mu.Lock() + defer wb.mu.Unlock() + var current float64 if enable { current = wb.curr } - return wb.setCurrent(current) + err := wb.setCurrent(current) + if err == nil { + wb.enabled = enable + } + return err } // MaxCurrent implements the api.Charger interface @@ -216,6 +258,9 @@ func (wb *ChargeX) MaxCurrentMillis(current float64) error { return fmt.Errorf("invalid current %.1f", current) } + wb.mu.Lock() + defer wb.mu.Unlock() + err := wb.setCurrent(current) if err == nil { wb.curr = current