Custom charger: add optional finish timer (#29600)
This commit is contained in:
parent
0178d32302
commit
975caea21c
7 changed files with 92 additions and 11 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue