Custom charger: add optional finish timer (#29600)

This commit is contained in:
Copilot 2026-05-04 17:47:25 +02:00 • committed by GitHub
parent 0178d32302
commit 975caea21c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 92 additions and 11 deletions

View file

@ -63,3 +63,6 @@ maxcurrent: # set maximum charge current in A
#wakeup: # wake up vehicle #wakeup: # wake up vehicle
# source: js # source: js
# script: console.log(wakeup); # script: console.log(wakeup);
#finishtime: # estimated finish time (RFC3339 timestamp, duration string, Unix timestamp, or seconds remaining)
# source: const
# value: "1h30m"

View file

@ -55,3 +55,6 @@ icon: heater # icon for UI purpose only
#maxcurrentmillis: # set maximum heating current in A with decimal precision #maxcurrentmillis: # set maximum heating current in A with decimal precision
# source: js # source: js
# script: console.log(maxcurrentmillis); # script: console.log(maxcurrentmillis);
#finishtime: # estimated finish time (RFC3339 timestamp, duration string, Unix timestamp, or seconds remaining)
# source: const
# value: "1h30m"

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/measurement" "github.com/evcc-io/evcc/charger/measurement"
@ -25,7 +26,7 @@ func init() {
registry.AddCtx(api.Custom, NewConfigurableFromConfig) registry.AddCtx(api.Custom, NewConfigurableFromConfig)
} }
//go:generate go tool decorate -f decorateCustom -b *Charger -r api.Charger -t api.ChargerEx,api.Identifier,api.PhaseSwitcher,api.Resurrector,api.Battery,api.SocLimiter,api.Meter,api.MeterEnergy,api.PhaseCurrents,api.PhaseVoltages //go:generate go tool decorate -f decorateCustom -b *Charger -r api.Charger -t api.ChargerEx,api.Identifier,api.PhaseSwitcher,api.Resurrector,api.Battery,api.SocLimiter,api.Meter,api.MeterEnergy,api.PhaseCurrents,api.PhaseVoltages,api.VehicleFinishTimer
// NewConfigurableFromConfig creates a new charger from config // NewConfigurableFromConfig creates a new charger from config
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
@ -37,6 +38,7 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
Wakeup *plugin.Config Wakeup *plugin.Config
Soc *plugin.Config Soc *plugin.Config
LimitSoc *plugin.Config LimitSoc *plugin.Config
FinishTime *plugin.Config
Tos bool Tos bool
measurement.Temperature `mapstructure:",squash"` // optional, for heating devices measurement.Temperature `mapstructure:",squash"` // optional, for heating devices
measurement.Energy `mapstructure:",squash"` // optional measurement.Energy `mapstructure:",squash"` // optional
@ -151,7 +153,16 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C
return nil, err return nil, err
} }
return decorateCustom(c, maxcurrentmillis, identify, phases1p3p, wakeup, soc, limitsoc, powerG, energyG, currentsG, voltagesG), nil // decorate finishtime
var finishTime func() (time.Time, error)
if cc.FinishTime != nil {
finishTime, err = cc.FinishTime.TimeGetter(ctx)
if err != nil {
return nil, fmt.Errorf("finishTime: %w", err)
}
}
return decorateCustom(c, maxcurrentmillis, identify, phases1p3p, wakeup, soc, limitsoc, powerG, energyG, currentsG, voltagesG, finishTime), nil
} }
// NewConfigurable creates a new charger // NewConfigurable creates a new charger

View file

@ -4,11 +4,12 @@ package charger
import ( import (
"reflect" "reflect"
"time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
) )
func decorateCustom(base *Charger, chargerEx func(float64) error, identifier func() (string, error), phaseSwitcher func(int) error, resurrector func() error, battery func() (float64, error), socLimiter func() (int64, error), meter func() (float64, error), meterEnergy func() (float64, error), phaseCurrents func() (float64, float64, float64, error), phaseVoltages func() (float64, float64, float64, error)) api.Charger { func decorateCustom(base *Charger, chargerEx func(float64) error, identifier func() (string, error), phaseSwitcher func(int) error, resurrector func() error, battery func() (float64, error), socLimiter func() (int64, error), meter func() (float64, error), meterEnergy func() (float64, error), phaseCurrents func() (float64, float64, float64, error), phaseVoltages func() (float64, float64, float64, error), vehicleFinishTimer func() (time.Time, error)) api.Charger {
caps := make(map[reflect.Type]any) caps := make(map[reflect.Type]any)
if chargerEx != nil { if chargerEx != nil {
@ -51,6 +52,10 @@ func decorateCustom(base *Charger, chargerEx func(float64) error, identifier fun
caps[reflect.TypeFor[api.PhaseVoltages]()] = &decorateCustomPhaseVoltagesImpl{phaseVoltages: phaseVoltages} caps[reflect.TypeFor[api.PhaseVoltages]()] = &decorateCustomPhaseVoltagesImpl{phaseVoltages: phaseVoltages}
} }
if vehicleFinishTimer != nil {
caps[reflect.TypeFor[api.VehicleFinishTimer]()] = &decorateCustomVehicleFinishTimerImpl{vehicleFinishTimer: vehicleFinishTimer}
}
if len(caps) == 0 { if len(caps) == 0 {
return base return base
} }
@ -150,3 +155,11 @@ type decorateCustomSocLimiterImpl struct {
func (impl *decorateCustomSocLimiterImpl) GetLimitSoc() (int64, error) { func (impl *decorateCustomSocLimiterImpl) GetLimitSoc() (int64, error) {
return impl.socLimiter() return impl.socLimiter()
} }
type decorateCustomVehicleFinishTimerImpl struct {
vehicleFinishTimer func() (time.Time, error)
}
func (impl *decorateCustomVehicleFinishTimerImpl) FinishTime() (time.Time, error) {
return impl.vehicleFinishTimer()
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
reg "github.com/evcc-io/evcc/util/registry" reg "github.com/evcc-io/evcc/util/registry"
) )
@ -163,3 +164,24 @@ func (c *Config) BytesSetter(ctx context.Context, param string) (func([]byte) er
return prov.BytesSetter(param) return prov.BytesSetter(param)
} }
// TimeGetter returns a getter that parses the plugin value as an estimated finish time.
// The value may be an RFC3339 timestamp, a Go duration string, or a numeric number of seconds.
func (c *Config) TimeGetter(ctx context.Context) (func() (time.Time, error), error) {
if c == nil {
return nil, nil
}
stringG, err := c.StringGetter(ctx)
if err != nil {
return nil, err
}
return func() (time.Time, error) {
s, err := stringG()
if err != nil {
return time.Time{}, err
}
return parseRelativeTime(s)
}, nil
}

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"math" "math"
"strconv" "strconv"
"time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
@ -42,3 +43,38 @@ func parseFloat(payload string) (float64, error) {
} }
return f, err return f, err
} }
// unixThreshold is the Unix timestamp for 2026-01-01 00:00:00 UTC, used to distinguish
// Unix timestamps from duration-in-seconds when parsing numeric finish times.
const unixThreshold = 1767225600
// parseRelativeTime parses a string into an absolute time.Time.
// Supported formats:
// - RFC3339 timestamp (e.g. "2026-05-03T14:00:00Z") → interpreted as absolute time
// - Go duration string (e.g. "1h30m") → interpreted as remaining duration, added to time.Now()
// - Numeric string ≥ 1767225600 (e.g. "1767225600") → interpreted as Unix timestamp (absolute time)
// - Numeric string < 1767225600 (e.g. "5400") → interpreted as remaining seconds, added to time.Now()
//
// For relative formats, time.Now() is evaluated at the time of each call, providing a
// fresh estimate. This matches the behavior of hardcoded charger/vehicle implementations.
func parseRelativeTime(s string) (time.Time, error) {
// Try RFC3339 timestamp first (absolute time)
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Try Go duration string (relative)
if d, err := time.ParseDuration(s); err == nil {
return time.Now().Add(d), nil
}
// Try numeric: Unix timestamp (absolute) or duration in seconds (relative)
if f, err := strconv.ParseFloat(s, 64); err == nil {
if f >= unixThreshold {
return time.Unix(int64(f), 0), nil
}
return time.Now().Add(time.Duration(f * float64(time.Second))), nil
}
return time.Time{}, fmt.Errorf("invalid finish time: %s", s)
}

View file

@ -142,17 +142,10 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.V
// decorate finishtime // decorate finishtime
var finishTime func() (time.Time, error) var finishTime func() (time.Time, error)
if cc.FinishTime != nil { if cc.FinishTime != nil {
stringG, err := cc.FinishTime.StringGetter(ctx) finishTime, err = cc.FinishTime.TimeGetter(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("finishTime: %w", err) return nil, fmt.Errorf("finishTime: %w", err)
} }
finishTime = func() (time.Time, error) {
s, err := stringG()
if err != nil {
return time.Time{}, err
}
return time.Parse(time.RFC3339, s)
}
} }
// decorate wakeup // decorate wakeup