diff --git a/assets/js/components/Config/defaultYaml/customCharger.yaml b/assets/js/components/Config/defaultYaml/customCharger.yaml index 21e901c4c..4233d0aa1 100644 --- a/assets/js/components/Config/defaultYaml/customCharger.yaml +++ b/assets/js/components/Config/defaultYaml/customCharger.yaml @@ -63,3 +63,6 @@ maxcurrent: # set maximum charge current in A #wakeup: # wake up vehicle # source: js # script: console.log(wakeup); +#finishtime: # estimated finish time (RFC3339 timestamp, duration string, Unix timestamp, or seconds remaining) +# source: const +# value: "1h30m" diff --git a/assets/js/components/Config/defaultYaml/customHeater.yaml b/assets/js/components/Config/defaultYaml/customHeater.yaml index dbeb76f8d..973af5e61 100644 --- a/assets/js/components/Config/defaultYaml/customHeater.yaml +++ b/assets/js/components/Config/defaultYaml/customHeater.yaml @@ -55,3 +55,6 @@ icon: heater # icon for UI purpose only #maxcurrentmillis: # set maximum heating current in A with decimal precision # source: js # script: console.log(maxcurrentmillis); +#finishtime: # estimated finish time (RFC3339 timestamp, duration string, Unix timestamp, or seconds remaining) +# source: const +# value: "1h30m" diff --git a/charger/charger.go b/charger/charger.go index bc246f82c..6dd658286 100644 --- a/charger/charger.go +++ b/charger/charger.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/charger/measurement" @@ -25,7 +26,7 @@ func init() { 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 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 Soc *plugin.Config LimitSoc *plugin.Config + FinishTime *plugin.Config Tos bool measurement.Temperature `mapstructure:",squash"` // optional, for heating devices measurement.Energy `mapstructure:",squash"` // optional @@ -151,7 +153,16 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.C 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 diff --git a/charger/charger_decorators.go b/charger/charger_decorators.go index 38a738f45..a5387ed7a 100644 --- a/charger/charger_decorators.go +++ b/charger/charger_decorators.go @@ -4,11 +4,12 @@ package charger import ( "reflect" + "time" "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) 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} } + if vehicleFinishTimer != nil { + caps[reflect.TypeFor[api.VehicleFinishTimer]()] = &decorateCustomVehicleFinishTimerImpl{vehicleFinishTimer: vehicleFinishTimer} + } + if len(caps) == 0 { return base } @@ -150,3 +155,11 @@ type decorateCustomSocLimiterImpl struct { func (impl *decorateCustomSocLimiterImpl) GetLimitSoc() (int64, error) { return impl.socLimiter() } + +type decorateCustomVehicleFinishTimerImpl struct { + vehicleFinishTimer func() (time.Time, error) +} + +func (impl *decorateCustomVehicleFinishTimerImpl) FinishTime() (time.Time, error) { + return impl.vehicleFinishTimer() +} diff --git a/plugin/config.go b/plugin/config.go index 47d7453ab..4956f8fb6 100644 --- a/plugin/config.go +++ b/plugin/config.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" 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) } + +// 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 +} diff --git a/plugin/helper.go b/plugin/helper.go index f50f908b9..d316ec589 100644 --- a/plugin/helper.go +++ b/plugin/helper.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "strconv" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" @@ -42,3 +43,38 @@ func parseFloat(payload string) (float64, error) { } 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) +} diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go index 5da4c7ab9..5d3c9e3d4 100644 --- a/vehicle/vehicle.go +++ b/vehicle/vehicle.go @@ -142,17 +142,10 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.V // decorate finishtime var finishTime func() (time.Time, error) if cc.FinishTime != nil { - stringG, err := cc.FinishTime.StringGetter(ctx) + finishTime, err = cc.FinishTime.TimeGetter(ctx) if err != nil { 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