2331 lines
72 KiB
Go
2331 lines
72 KiB
Go
package core
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"reflect"
|
||
"slices"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
evbus "github.com/asaskevich/EventBus"
|
||
"github.com/benbjohnson/clock"
|
||
"github.com/cenkalti/backoff/v4"
|
||
"github.com/evcc-io/evcc/api"
|
||
"github.com/evcc-io/evcc/core/coordinator"
|
||
"github.com/evcc-io/evcc/core/keys"
|
||
"github.com/evcc-io/evcc/core/loadpoint"
|
||
"github.com/evcc-io/evcc/core/metrics"
|
||
"github.com/evcc-io/evcc/core/planner"
|
||
"github.com/evcc-io/evcc/core/session"
|
||
"github.com/evcc-io/evcc/core/settings"
|
||
"github.com/evcc-io/evcc/core/site"
|
||
"github.com/evcc-io/evcc/core/soc"
|
||
"github.com/evcc-io/evcc/core/wrapper"
|
||
"github.com/evcc-io/evcc/messenger"
|
||
"github.com/evcc-io/evcc/util"
|
||
"github.com/evcc-io/evcc/util/config"
|
||
"github.com/evcc-io/evcc/util/modbus"
|
||
"github.com/evcc-io/evcc/util/telemetry"
|
||
)
|
||
|
||
const (
|
||
evChargeStart = "start" // update chargeTimer
|
||
evChargeStop = "stop" // update chargeTimer
|
||
evChargeCurrent = "current" // update fakeChargeMeter
|
||
evChargePower = "power" // update chargeRater
|
||
evVehicleConnect = "connect" // vehicle connected
|
||
evVehicleDisconnect = "disconnect" // vehicle disconnected
|
||
evVehicleSoc = "soc" // vehicle soc progress
|
||
evVehicleUnidentified = "guest" // vehicle unidentified
|
||
evVehicleAsleep = "asleep" // vehicle doesn't charge
|
||
|
||
pvTimer = "pv"
|
||
pvEnable = "enable"
|
||
pvDisable = "disable"
|
||
|
||
phaseTimer = "phase"
|
||
phaseScale1p = "scale1p"
|
||
phaseScale3p = "scale3p"
|
||
|
||
timerInactive = "inactive"
|
||
|
||
minActiveCurrent = 1.0 // minimum current at which a phase is treated as active
|
||
minActiveVoltage = 207 // minimum voltage at which a phase is treated as active
|
||
|
||
maxSessionEnergy = 1e3 // kWh, above this a charge rater reading is treated as garbage
|
||
|
||
chargerSwitchDuration = 60 * time.Second // allow out of sync during this timespan
|
||
phaseSwitchDuration = 60 * time.Second // allow out of sync and do not measure phases during this timespan
|
||
|
||
// battery boost states
|
||
boostDisabled = 0
|
||
boostStart = 1
|
||
boostContinue = 2
|
||
boostHold = 3 // soc limit reached: stop draining but keep vehicle priority over recharging
|
||
)
|
||
|
||
// elapsed is the time an expired timer will be set to
|
||
var elapsed = time.Unix(0, 1)
|
||
|
||
// Poll modes
|
||
const pollInterval = 60 * time.Minute
|
||
|
||
// Task is the task type
|
||
type Task = func()
|
||
|
||
// Loadpoint is responsible for controlling charge depending on
|
||
// Soc needs and power availability.
|
||
type Loadpoint struct {
|
||
clock clock.Clock // mockable time
|
||
bus evbus.Bus // event bus
|
||
site site.API
|
||
pushChan chan<- messenger.Event // notifications
|
||
uiChan chan<- util.Param // client push messages
|
||
lpChan chan<- *Loadpoint // update requests
|
||
log *util.Logger
|
||
|
||
rwMutex atomic.Int64 // count reentrant RWMutex
|
||
sync.RWMutex // guard status
|
||
vmu sync.RWMutex // guard vehicle
|
||
|
||
// exposed public configuration
|
||
CircuitRef string `mapstructure:"circuit"` // Circuit reference
|
||
ChargerRef string `mapstructure:"charger"` // Charger reference
|
||
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
|
||
MeterRef string `mapstructure:"meter"` // Charge meter reference
|
||
|
||
Soc loadpoint.SocConfig
|
||
Enable, Disable loadpoint.ThresholdConfig
|
||
Ui loadpoint.UIConfig // display-only, not used in control logic
|
||
|
||
// from yaml
|
||
DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
|
||
Title string `mapstructure:"title"` // UI title
|
||
Priority int `mapstructure:"priority"` // Priority
|
||
|
||
// from yaml, deprecated
|
||
GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility
|
||
Phases_ int `mapstructure:"phases"` // ignored, present for compatibility
|
||
MinCurrent_ float64 `mapstructure:"minCurrent"` // ignored, present for compatibility
|
||
MaxCurrent_ float64 `mapstructure:"maxCurrent"` // ignored, present for compatibility
|
||
|
||
title string // UI title
|
||
priority int // Priority
|
||
minCurrent float64 // PV mode: start current Min+PV mode: min current
|
||
maxCurrent float64 // Max allowed current. Physically ensured by the charger
|
||
phasesConfigured int // Charger configured phase mode 0/1/3
|
||
limitSoc int // Session limit for soc
|
||
limitEnergy float64 // Session limit for energy
|
||
minSoc int // Forced charging below this soc (heating: temperature), 0=disabled
|
||
smartCostLimit *float64 // always charge if consumption cost is below this value
|
||
smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value
|
||
batteryBoost int // battery boost state
|
||
batteryBoostLimit int // battery boost soc limit (0-100, 100=disabled)
|
||
|
||
mode api.ChargeMode
|
||
enabled bool // Charger enabled state
|
||
phases int // Charger enabled phases, guarded by mutex
|
||
measuredPhases int // Charger physically measured phases
|
||
offeredCurrent float64 // Charger current limit
|
||
socUpdated time.Time // Soc updated timestamp (poll: connected)
|
||
vehicleDetect time.Time // Vehicle connected timestamp
|
||
chargerSwitched time.Time // Charger enabled/disabled timestamp
|
||
phasesSwitched time.Time // Phase switch timestamp
|
||
vehicleDetectTicker *clock.Ticker
|
||
vehicleIdentifier string
|
||
|
||
charger api.Charger
|
||
chargeTimer api.ChargeTimer
|
||
chargeRater api.ChargeRater
|
||
chargedAtStartup float64 // session energy at startup
|
||
|
||
circuit api.Circuit // Circuit
|
||
chargeMeter api.Meter // Charger usage meter
|
||
chargeEnergy *metrics.Collector // Charger usage collector
|
||
vehicle api.Vehicle // Currently active vehicle
|
||
defaultVehicle api.Vehicle // Default vehicle (disables detection)
|
||
coordinator coordinator.API
|
||
socEstimator *soc.Estimator
|
||
|
||
// charge planning
|
||
planner *planner.Planner
|
||
planTime time.Time // time goal
|
||
planStrategy api.PlanStrategy // plan strategy (precondition, continuous)
|
||
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
|
||
planEnergyOffset float64 // already charged energy in kWh when plan was set
|
||
planSlotEnd time.Time // current plan slot end time
|
||
planActive bool // charge plan exists and has a currently active slot
|
||
planOverrunSent bool // notification has been sent already
|
||
planLocked PlanLock // locked plan
|
||
|
||
// cached state
|
||
status api.ChargeStatus // Charger status
|
||
chargePower float64 // Charging power
|
||
chargeCurrents []float64 // Phase currents
|
||
connectedTime time.Time // Time when vehicle was connected
|
||
connectPending bool // connect notification deferred until vehicle detection settles
|
||
pvTimer time.Time // PV enabled/disable timer
|
||
phaseTimer time.Time // 1p3p switch timer
|
||
wakeUpTimer *Timer // Vehicle wake-up timeout
|
||
|
||
// charge progress
|
||
vehicleSoc float64 // Vehicle or charger soc
|
||
vehicleRange int64 // Vehicle range in km
|
||
chargeDuration time.Duration // Charge duration
|
||
connectedDuration time.Duration // Connection duration
|
||
energyMetrics EnergyMetrics // Stats for charged energy by session
|
||
chargeRemainingDuration time.Duration // Remaining charge duration
|
||
chargeRemainingEnergy float64 // Remaining charge energy in kWh
|
||
progress *Progress // Step-wise progress indicator
|
||
|
||
// session log
|
||
db *session.DB
|
||
session *session.Session
|
||
|
||
settings settings.Settings
|
||
|
||
tasks *util.Queue[Task] // tasks to be executed
|
||
}
|
||
|
||
// NewLoadpointFromConfig creates a new loadpoint
|
||
func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, collector *metrics.Collector, other map[string]any) (*Loadpoint, error) {
|
||
lp := NewLoadpoint(log, settings)
|
||
if err := util.DecodeOther(other, lp); err != nil {
|
||
return lp, err
|
||
}
|
||
|
||
// set vehicle polling mode
|
||
switch lp.Soc.Poll.Mode {
|
||
case loadpoint.PollCharging:
|
||
case loadpoint.PollConnected, loadpoint.PollAlways:
|
||
lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
|
||
default:
|
||
lp.Soc.Poll.Mode = loadpoint.PollCharging
|
||
}
|
||
|
||
// validate thresholds
|
||
if lp.Enable.Threshold > lp.Disable.Threshold {
|
||
lp.log.WARN.Printf("PV mode enable threshold (%.0fW) is larger than disable threshold (%.0fW)", lp.Enable.Threshold, lp.Disable.Threshold)
|
||
} else if lp.Enable.Threshold > 0 {
|
||
lp.log.WARN.Printf("PV mode enable threshold %.0fW > 0 will start PV charging on grid power consumption. Did you mean -%.0f?", lp.Enable.Threshold, lp.Enable.Threshold)
|
||
}
|
||
|
||
// choose sane default if mode is not set
|
||
if lp.mode = lp.DefaultMode; lp.mode == "" {
|
||
lp.mode = api.ModeOff
|
||
}
|
||
|
||
if lp.Priority > 0 {
|
||
lp.setPriority(lp.Priority)
|
||
}
|
||
|
||
if lp.CircuitRef != "" {
|
||
dev, err := config.Circuits().ByName(lp.CircuitRef)
|
||
if err != nil {
|
||
return lp, fmt.Errorf("circuit: %w", err)
|
||
}
|
||
lp.circuit = dev.Instance()
|
||
if lp.circuit == nil {
|
||
return lp, errors.New("missing circuit instance")
|
||
}
|
||
}
|
||
|
||
if lp.MeterRef != "" {
|
||
dev, err := config.Meters().ByName(lp.MeterRef)
|
||
if err != nil {
|
||
return lp, fmt.Errorf("meter: %w", err)
|
||
}
|
||
lp.chargeMeter = dev.Instance()
|
||
if lp.chargeMeter == nil {
|
||
return lp, errors.New("missing charge meter instance")
|
||
}
|
||
}
|
||
|
||
// default vehicle
|
||
if lp.VehicleRef != "" {
|
||
dev, err := config.Vehicles().ByName(lp.VehicleRef)
|
||
if err != nil {
|
||
return lp, fmt.Errorf("default vehicle: %w", err)
|
||
}
|
||
lp.defaultVehicle = dev.Instance()
|
||
if lp.defaultVehicle == nil {
|
||
// disabled vehicle
|
||
lp.log.DEBUG.Printf("default vehicle '%s' is disabled", lp.VehicleRef)
|
||
}
|
||
}
|
||
|
||
if lp.ChargerRef == "" {
|
||
return lp, errors.New("missing charger")
|
||
}
|
||
|
||
dev, err := config.Chargers().ByName(lp.ChargerRef)
|
||
if err != nil {
|
||
return lp, fmt.Errorf("charger: %w", err)
|
||
}
|
||
lp.charger = dev.Instance()
|
||
if lp.charger == nil {
|
||
return lp, errors.New("missing charger instance")
|
||
}
|
||
|
||
lp.configureChargerType(lp.charger)
|
||
// add collector
|
||
if lp.chargeMeter != nil {
|
||
lp.chargeEnergy = collector
|
||
}
|
||
|
||
// set title after collector is wired to refresh the metrics entity
|
||
if lp.Title != "" {
|
||
lp.setTitle(lp.Title)
|
||
}
|
||
|
||
// phase switching defaults based on charger capabilities
|
||
if !lp.hasPhaseSwitching() {
|
||
phases := lp.getChargerPhysicalPhases()
|
||
if phases == 0 {
|
||
phases = 3 // default to 3p if no charger phases are known
|
||
}
|
||
|
||
lp.phasesConfigured = phases
|
||
lp.phases = phases
|
||
}
|
||
|
||
return lp, nil
|
||
}
|
||
|
||
// NewLoadpoint creates a Loadpoint with sane defaults
|
||
func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint {
|
||
clock := clock.New()
|
||
bus := evbus.New()
|
||
|
||
lp := &Loadpoint{
|
||
log: log, // logger
|
||
settings: settings, // settings
|
||
clock: clock, // mockable time
|
||
bus: bus, // event bus
|
||
mode: api.ModeOff,
|
||
status: api.StatusNone,
|
||
minCurrent: 6, // A
|
||
maxCurrent: 16, // A
|
||
batteryBoostLimit: 100, // disabled
|
||
Soc: loadpoint.SocConfig{
|
||
Poll: loadpoint.PollConfig{
|
||
Interval: pollInterval,
|
||
Mode: loadpoint.PollCharging,
|
||
},
|
||
},
|
||
Enable: loadpoint.ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
|
||
Disable: loadpoint.ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
|
||
progress: NewProgress(0, 10), // soc progress indicator
|
||
coordinator: coordinator.NewDummy(), // dummy vehicle coordinator
|
||
tasks: util.NewQueue[Task](), // task queue
|
||
}
|
||
|
||
return lp
|
||
}
|
||
|
||
// restoreSettings restores loadpoint settings
|
||
func (lp *Loadpoint) restoreSettings() {
|
||
if testing.Testing() {
|
||
return
|
||
}
|
||
|
||
// deprecated yaml properties
|
||
if lp.Phases_ > 0 {
|
||
lp.log.WARN.Printf("ignoring deprecated phases: %d. please configure via UI", lp.Phases_)
|
||
}
|
||
if lp.MinCurrent_ > 0 {
|
||
lp.log.WARN.Printf("ignoring deprecated minCurrent: %f. please configure via UI", lp.MinCurrent_)
|
||
}
|
||
if lp.MaxCurrent_ > 0 {
|
||
lp.log.WARN.Printf("ignoring deprecated maxCurrent: %f. please configure via UI", lp.MaxCurrent_)
|
||
}
|
||
if lp.GuardDuration_ > 0 {
|
||
lp.log.WARN.Printf("ignoring deprecated guardduration: %s. please configure via UI", lp.GuardDuration_)
|
||
}
|
||
|
||
// restore runtime configuration (database & yaml LPs)
|
||
if v, err := lp.settings.String(keys.Mode); err == nil && v != "" && lp.DefaultMode == api.ModeEmpty {
|
||
lp.setMode(api.ChargeMode(v))
|
||
}
|
||
if v, err := lp.settings.Int(keys.Priority); err == nil {
|
||
lp.setPriority(int(v))
|
||
}
|
||
if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
|
||
lp.setPhasesConfigured(int(v))
|
||
}
|
||
if v, err := lp.settings.Float(keys.MinCurrent); err == nil && v > 0 {
|
||
lp.setMinCurrent(v)
|
||
}
|
||
if v, err := lp.settings.Float(keys.MaxCurrent); err == nil && v > 0 {
|
||
lp.setMaxCurrent(v)
|
||
}
|
||
if v, err := lp.settings.Int(keys.LimitSoc); err == nil && v > 0 {
|
||
lp.setLimitSoc(int(v))
|
||
}
|
||
if v, err := lp.settings.Int(keys.MinSoc); err == nil && v > 0 {
|
||
lp.setMinSoc(int(v))
|
||
}
|
||
if v, err := lp.settings.Float(keys.LimitEnergy); err == nil && v > 0 {
|
||
lp.setLimitEnergy(v)
|
||
}
|
||
if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
|
||
lp.SetSmartCostLimit(&v)
|
||
}
|
||
if v, err := lp.settings.Float(keys.SmartFeedInPriorityLimit); err == nil {
|
||
lp.SetSmartFeedInPriorityLimit(&v)
|
||
}
|
||
if v, err := lp.settings.Int(keys.BatteryBoostLimit); err == nil {
|
||
lp.SetBatteryBoostLimit(int(v))
|
||
}
|
||
|
||
var thresholds loadpoint.ThresholdsConfig
|
||
if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil {
|
||
lp.setThresholds(thresholds)
|
||
}
|
||
|
||
var socConfig loadpoint.SocConfig
|
||
if err := lp.settings.Json(keys.Soc, &socConfig); err == nil {
|
||
lp.setSocConfig(socConfig)
|
||
}
|
||
|
||
var ui loadpoint.UIConfig
|
||
if err := lp.settings.Json(keys.UI, &ui); err == nil {
|
||
lp.Ui = ui
|
||
}
|
||
|
||
t, err1 := lp.settings.Time(keys.PlanTime)
|
||
v, err2 := lp.settings.Float(keys.PlanEnergy)
|
||
if err1 == nil && err2 == nil {
|
||
lp.setPlanEnergy(t, v)
|
||
}
|
||
|
||
// load plan strategy (continuous mode and precondition duration)
|
||
var planStrategy api.PlanStrategy
|
||
if err := lp.settings.Json(keys.PlanStrategy, &planStrategy); err == nil {
|
||
lp.setPlanStrategy(planStrategy)
|
||
}
|
||
}
|
||
|
||
// requestUpdate requests site to update this loadpoint
|
||
func (lp *Loadpoint) requestUpdate() {
|
||
select {
|
||
case lp.lpChan <- lp: // request loadpoint update
|
||
default:
|
||
}
|
||
}
|
||
|
||
// configureChargerType ensures that chargeMeter, Rate and Timer can use charger capabilities
|
||
func (lp *Loadpoint) configureChargerType(charger api.Charger) {
|
||
var integrated bool
|
||
|
||
// ensure charge meter exists
|
||
if lp.chargeMeter == nil {
|
||
integrated = true
|
||
|
||
if mt, ok := api.Cap[api.Meter](charger); ok {
|
||
// preserve charger's capability registry and static interface
|
||
// implementations so that subsequent capability checks on
|
||
// chargeMeter (e.g. MeterEnergy, PhaseCurrents) still work for
|
||
// decorated chargers (https://github.com/evcc-io/evcc/issues/28915)
|
||
// and for chargers that statically implement these interfaces
|
||
// (https://github.com/evcc-io/evcc/issues/29877).
|
||
lp.chargeMeter = &capableMeter{Meter: mt, source: charger}
|
||
} else {
|
||
mt := new(wrapper.ChargeMeter)
|
||
_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentWrappedMeterHandler)
|
||
_ = lp.bus.Subscribe(evChargeStop, func() { mt.SetPower(0) })
|
||
lp.chargeMeter = mt
|
||
}
|
||
}
|
||
|
||
// ensure charge rater exists
|
||
// measurement are obtained from separate charge meter if defined
|
||
// (https://github.com/evcc-io/evcc/issues/2469)
|
||
if rt, ok := api.Cap[api.ChargeRater](charger); ok && integrated {
|
||
lp.chargeRater = rt
|
||
|
||
// when restarting in the middle of charging session, use this as negative offset
|
||
if f, err := rt.ChargedEnergy(); err == nil {
|
||
lp.chargedAtStartup = f
|
||
}
|
||
} else {
|
||
rt := wrapper.NewChargeRater(lp.log, lp.chargeMeter)
|
||
_ = lp.bus.Subscribe(evChargePower, rt.SetChargePower)
|
||
_ = lp.bus.Subscribe(evVehicleConnect, func() { rt.StartCharge(false) })
|
||
_ = lp.bus.Subscribe(evChargeStart, func() { rt.StartCharge(true) })
|
||
_ = lp.bus.Subscribe(evChargeStop, rt.StopCharge)
|
||
lp.chargeRater = rt
|
||
}
|
||
|
||
// ensure charge timer exists
|
||
if ct, ok := api.Cap[api.ChargeTimer](charger); ok {
|
||
lp.chargeTimer = ct
|
||
} else {
|
||
ct := wrapper.NewChargeTimer()
|
||
_ = lp.bus.Subscribe(evVehicleConnect, func() { ct.StartCharge(false) })
|
||
_ = lp.bus.Subscribe(evChargeStart, func() { ct.StartCharge(true) })
|
||
_ = lp.bus.Subscribe(evChargeStop, ct.StopCharge)
|
||
lp.chargeTimer = ct
|
||
}
|
||
|
||
// add wakeup timer
|
||
lp.wakeUpTimer = NewTimer()
|
||
}
|
||
|
||
// pushEvent sends push messages to clients
|
||
func (lp *Loadpoint) pushEvent(event string) {
|
||
lp.pushChan <- messenger.Event{Event: event}
|
||
}
|
||
|
||
// maybePushVehicleConnect sends a deferred connect notification once vehicle
|
||
// detection has settled
|
||
func (lp *Loadpoint) maybePushVehicleConnect() {
|
||
if !lp.connectPending {
|
||
return
|
||
}
|
||
|
||
// hold while detection is still running and the vehicle is unidentified
|
||
if lp.GetVehicle() == nil && !lp.vehicleDetect.IsZero() {
|
||
return
|
||
}
|
||
|
||
lp.connectPending = false
|
||
lp.pushEvent(evVehicleConnect)
|
||
}
|
||
|
||
// publish sends values to UI and databases
|
||
func (lp *Loadpoint) publish(key string, val any) {
|
||
// test helper
|
||
if lp.uiChan == nil {
|
||
return
|
||
}
|
||
|
||
lp.uiChan <- util.Param{Key: key, Val: val}
|
||
}
|
||
|
||
// evChargeStartHandler sends external start event
|
||
func (lp *Loadpoint) evChargeStartHandler() {
|
||
lp.log.INFO.Println("start charging ->")
|
||
lp.pushEvent(evChargeStart)
|
||
|
||
// charge status
|
||
lp.publish(keys.ChargerStatusReason, api.ReasonUnknown)
|
||
|
||
lp.stopWakeUpTimer()
|
||
|
||
// soc update reset
|
||
lp.socUpdated = time.Time{}
|
||
|
||
// set created when first charging session segment starts
|
||
lp.updateSession(func(session *session.Session) {
|
||
if session.Created.IsZero() {
|
||
session.Created = lp.clock.Now()
|
||
}
|
||
// capture start soc once available (may not be present at session start)
|
||
if soc := lp.vehicleSoc; session.SocStart == nil && soc > 0 && !lp.chargerHasFeature(api.Heating) {
|
||
session.SocStart = &soc
|
||
}
|
||
})
|
||
}
|
||
|
||
// evChargeStopHandler sends external stop event
|
||
func (lp *Loadpoint) evChargeStopHandler() {
|
||
lp.log.INFO.Println("stop charging <-")
|
||
lp.pushEvent(evChargeStop)
|
||
|
||
// soc update reset
|
||
util.ResetCached()
|
||
lp.socUpdated = time.Time{}
|
||
|
||
// reset pv enable/disable timer
|
||
// https://github.com/evcc-io/evcc/issues/2289
|
||
if !lp.pvTimer.Equal(elapsed) {
|
||
lp.resetPVTimer()
|
||
}
|
||
|
||
lp.stopSession()
|
||
}
|
||
|
||
// evVehicleConnectHandler sends external start event
|
||
func (lp *Loadpoint) evVehicleConnectHandler() {
|
||
lp.log.INFO.Printf("car connected")
|
||
|
||
// duration
|
||
lp.connectedTime = lp.clock.Now()
|
||
lp.publish(keys.ConnectedDuration, time.Duration(0))
|
||
|
||
// soc update reset
|
||
lp.socUpdated = time.Time{}
|
||
|
||
// charger may have reconfigured phases internally while disconnected
|
||
if err := lp.syncChargerPhases(); err != nil {
|
||
lp.log.ERROR.Println(err)
|
||
}
|
||
|
||
// set default or start detection
|
||
if !lp.chargerHasFeature(api.IntegratedDevice) {
|
||
lp.vehicleDefaultOrDetect()
|
||
}
|
||
|
||
// immediately allow pv mode activity
|
||
lp.elapsePVTimer()
|
||
|
||
// create charging session
|
||
lp.createSession()
|
||
|
||
// reset energy-based charging plan offset
|
||
lp.planEnergyOffset = 0
|
||
|
||
// connect adds the loadpoint's demand
|
||
lp.triggerOptimizer()
|
||
}
|
||
|
||
// evVehicleDisconnectHandler sends external start event
|
||
func (lp *Loadpoint) evVehicleDisconnectHandler() {
|
||
lp.log.INFO.Println("car disconnected")
|
||
|
||
// re-read energy from charger and re-persist session if values improved
|
||
lp.finalizeSessionEnergy()
|
||
|
||
// re-read odometer to catch delayed update (#30225)
|
||
lp.vehicleOdometer()
|
||
|
||
// session is persisted during evChargeStopHandler which runs before
|
||
lp.clearSession()
|
||
|
||
// clear locked plan goal on disconnect
|
||
lp.clearPlanLock()
|
||
|
||
// phases are unknown when vehicle disconnects
|
||
lp.ResetMeasuredPhases()
|
||
|
||
// energy and duration
|
||
lp.energyMetrics.Publish("session", lp)
|
||
lp.publish(keys.ChargedEnergy, lp.GetChargedEnergy())
|
||
lp.publish(keys.ConnectedDuration, lp.clock.Since(lp.connectedTime).Round(time.Second))
|
||
|
||
// charge status
|
||
lp.publish(keys.ChargerStatusReason, api.ReasonUnknown)
|
||
|
||
// forget startup energy offset
|
||
lp.chargedAtStartup = 0
|
||
|
||
// remove charger vehicle id and stop potential detection
|
||
lp.setVehicleIdentifier("")
|
||
lp.stopVehicleDetection()
|
||
|
||
// set default mode on disconnect
|
||
// skip for integrated devices: the "disconnect" here is just the socket
|
||
// being switched off, not a vehicle being unplugged - keep the user's mode (#30187)
|
||
if !lp.chargerHasFeature(api.IntegratedDevice) {
|
||
lp.defaultMode()
|
||
}
|
||
|
||
// set default vehicle (may be nil)
|
||
lp.setActiveVehicle(lp.defaultVehicle)
|
||
|
||
// soc update reset
|
||
lp.socUpdated = time.Time{}
|
||
|
||
// boost
|
||
if err := lp.SetBatteryBoost(false); err != nil {
|
||
lp.log.ERROR.Printf("battery boost: %v", err)
|
||
}
|
||
|
||
// reset session
|
||
if !lp.chargerHasFeature(api.IntegratedDevice) {
|
||
lp.SetLimitSoc(0)
|
||
lp.SetLimitEnergy(0)
|
||
}
|
||
|
||
// mark plan slot as inactive
|
||
// this will force a deletion of an outdated plan once plan time is expired in GetPlan()
|
||
lp.setPlanActive(false)
|
||
|
||
// disconnect removes the loadpoint's demand
|
||
lp.triggerOptimizer()
|
||
}
|
||
|
||
// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed
|
||
func (lp *Loadpoint) triggerOptimizer() {
|
||
if lp.site != nil {
|
||
lp.site.Optimize()
|
||
}
|
||
}
|
||
|
||
// evVehicleSocProgressHandler sends external start event
|
||
func (lp *Loadpoint) evVehicleSocProgressHandler(soc float64) {
|
||
if lp.progress.NextStep(soc) {
|
||
lp.pushEvent(evVehicleSoc)
|
||
}
|
||
}
|
||
|
||
// evChargeCurrentHandler publishes the offered current
|
||
func (lp *Loadpoint) evChargeCurrentHandler(current float64) {
|
||
if !lp.enabled {
|
||
current = 0
|
||
}
|
||
lp.publish(keys.OfferedCurrent, current)
|
||
}
|
||
|
||
// evChargeCurrentWrappedMeterHandler updates the dummy charge meter's charge power.
|
||
// This simplifies the main flow where the charge meter can always be treated as present.
|
||
// It assumes that the charge meter cannot consume more than total household consumption.
|
||
// If physical charge meter is present this handler is not used.
|
||
// The actual value is published by the evChargeCurrentHandler
|
||
func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) {
|
||
power := current * float64(lp.ActivePhases()) * Voltage
|
||
|
||
// if disabled we cannot be charging
|
||
if !lp.enabled || !lp.charging() {
|
||
power = 0
|
||
}
|
||
|
||
// handler only called if charge meter was replaced by dummy
|
||
lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
|
||
}
|
||
|
||
// defaultMode executes the action
|
||
func (lp *Loadpoint) defaultMode() {
|
||
lp.RLock()
|
||
mode := lp.DefaultMode
|
||
lp.RUnlock()
|
||
|
||
if mode != "" && mode != lp.GetMode() {
|
||
lp.SetMode(mode)
|
||
}
|
||
}
|
||
|
||
// Prepare loadpoint configuration by adding missing helper elements
|
||
func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan chan<- messenger.Event, lpChan chan<- *Loadpoint) {
|
||
lp.site = site
|
||
lp.uiChan = uiChan
|
||
lp.pushChan = pushChan
|
||
lp.lpChan = lpChan
|
||
|
||
// event handlers
|
||
_ = lp.bus.Subscribe(evChargeStart, lp.evChargeStartHandler)
|
||
_ = lp.bus.Subscribe(evChargeStop, lp.evChargeStopHandler)
|
||
_ = lp.bus.Subscribe(evVehicleConnect, lp.evVehicleConnectHandler)
|
||
_ = lp.bus.Subscribe(evVehicleDisconnect, lp.evVehicleDisconnectHandler)
|
||
_ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentHandler)
|
||
_ = lp.bus.Subscribe(evVehicleSoc, lp.evVehicleSocProgressHandler)
|
||
|
||
// restore settings
|
||
lp.restoreSettings()
|
||
|
||
// publish initial values
|
||
lp.publish(keys.Title, lp.GetTitle())
|
||
lp.publish(keys.Mode, lp.GetMode())
|
||
lp.publish(keys.Priority, lp.GetPriority())
|
||
lp.publish(keys.MinCurrent, lp.GetMinCurrent())
|
||
lp.publish(keys.MaxCurrent, lp.GetMaxCurrent())
|
||
|
||
lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
|
||
lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
|
||
lp.publish(keys.EnableDelay, lp.Enable.Delay)
|
||
lp.publish(keys.DisableDelay, lp.Disable.Delay)
|
||
|
||
lp.publish(keys.UI, lp.Ui)
|
||
|
||
if phases := lp.getChargerPhysicalPhases(); phases != 0 {
|
||
if lp.phasesConfigured != phases && lp.phasesConfigured != 0 {
|
||
lp.log.WARN.Printf("configured phases %d do not match physical phases %d", lp.phasesConfigured, phases)
|
||
}
|
||
lp.phases = phases
|
||
lp.phasesConfigured = phases
|
||
}
|
||
|
||
lp.publish(keys.PhasesConfigured, lp.phasesConfigured)
|
||
lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
|
||
lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
|
||
lp.publish(keys.PhasesActive, lp.ActivePhases())
|
||
lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
|
||
lp.publish(keys.SmartFeedInPriorityLimit, lp.smartFeedInPriorityLimit)
|
||
lp.publishTimer(phaseTimer, 0, timerInactive)
|
||
lp.publishTimer(pvTimer, 0, timerInactive)
|
||
lp.publishEnergyStats()
|
||
|
||
// charger features
|
||
for _, f := range api.FeatureValues() {
|
||
lp.publishChargerFeature(f)
|
||
}
|
||
|
||
// charger icon
|
||
if c, ok := api.Cap[api.IconDescriber](lp.charger); ok {
|
||
lp.publish(keys.ChargerIcon, c.Icon())
|
||
} else {
|
||
lp.publish(keys.ChargerIcon, nil)
|
||
}
|
||
|
||
// vehicle
|
||
lp.unpublishVehicleIdentity()
|
||
lp.unpublishVehicle()
|
||
|
||
// assign and publish default vehicle
|
||
if lp.defaultVehicle != nil {
|
||
lp.setActiveVehicle(lp.defaultVehicle)
|
||
}
|
||
|
||
// reset detection state
|
||
lp.publish(keys.VehicleDetectionActive, false)
|
||
|
||
// restored settings
|
||
lp.publish(keys.PlanTime, lp.planTime)
|
||
lp.publish(keys.PlanEnergy, lp.planEnergy)
|
||
lp.publish(keys.PlanStrategy, lp.planStrategy)
|
||
lp.publish(keys.LimitSoc, lp.limitSoc)
|
||
lp.publish(keys.LimitEnergy, lp.limitEnergy)
|
||
lp.publish(keys.MinSoc, lp.minSoc)
|
||
|
||
// planner
|
||
lp.publish(keys.PlanActive, lp.planActive)
|
||
|
||
// battery boost
|
||
lp.publish(keys.BatteryBoost, lp.batteryBoost != boostDisabled)
|
||
lp.publish(keys.BatteryBoostLimit, lp.batteryBoostLimit)
|
||
|
||
// read initial charger state to prevent immediately disabling charger
|
||
if enabled, err := lp.charger.Enabled(); err == nil {
|
||
if lp.enabled = enabled; enabled {
|
||
// set defined current for use by pv mode
|
||
_ = lp.setLimit(lp.effectiveMinCurrent())
|
||
}
|
||
} else {
|
||
lp.log.ERROR.Printf("charger enabled: %v", err)
|
||
}
|
||
|
||
// set vehicle polling mode
|
||
if lp.Soc.Poll.Mode != loadpoint.PollCharging {
|
||
lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
|
||
}
|
||
|
||
// allow charger to access loadpoint
|
||
if ctrl, ok := lp.charger.(loadpoint.Controller); ok {
|
||
ctrl.LoadpointControl(lp)
|
||
}
|
||
}
|
||
|
||
func (lp *Loadpoint) setAndPublishEnabled(enabled bool) {
|
||
if enabled != lp.enabled {
|
||
lp.log.DEBUG.Printf("charger %s", status[enabled])
|
||
lp.enabled = enabled
|
||
}
|
||
lp.publish(keys.Enabled, enabled)
|
||
}
|
||
|
||
// syncCharger updates charger status and synchronizes it with expectations
|
||
func (lp *Loadpoint) syncCharger() error {
|
||
enabled, err := lp.charger.Enabled()
|
||
if err != nil {
|
||
return fmt.Errorf("charger enabled: %w", err)
|
||
}
|
||
|
||
shouldBeConsistent := lp.shouldBeConsistent()
|
||
|
||
if shouldBeConsistent {
|
||
defer func() {
|
||
lp.setAndPublishEnabled(enabled)
|
||
}()
|
||
}
|
||
|
||
// #1: check charger logic, fix charger state if necessary (for chargers that start charging while being disabled)
|
||
if !enabled && lp.charging() {
|
||
lp.log.WARN.Println("charger logic error: disabled but charging")
|
||
|
||
// treat as enabled when charging for further validations
|
||
enabled = true
|
||
|
||
if shouldBeConsistent {
|
||
if err := lp.charger.Enable(true); err != nil { // also enable charger to correct internal state
|
||
return fmt.Errorf("charger enable: %w", err)
|
||
}
|
||
|
||
lp.elapsePVTimer() // elapse PV timer so loadpoint can immediately switch charger if necessary
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// #2: sync charger
|
||
switch {
|
||
case enabled && lp.enabled:
|
||
// sync max current
|
||
var (
|
||
current float64
|
||
err error
|
||
)
|
||
|
||
// use chargers actual set current if available
|
||
cg, isCg := api.Cap[api.CurrentGetter](lp.charger)
|
||
if isCg {
|
||
if current, err = cg.GetMaxCurrent(); err == nil {
|
||
// smallest adjustment most PWM-Controllers can do is: 100%÷256×0,6A = 0.234A
|
||
if delta := math.Abs(lp.offeredCurrent - current); delta > 0.23 {
|
||
if shouldBeConsistent && delta >= 1 {
|
||
lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA, expected %.3gA) - make sure your interval is at least 30s", current, lp.offeredCurrent)
|
||
}
|
||
lp.offeredCurrent = current
|
||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||
}
|
||
} else if !loadpoint.AcceptableError(err) {
|
||
return fmt.Errorf("charger get max current: %w", err)
|
||
}
|
||
}
|
||
|
||
// use measured phase currents as fallback if charger does not provide max current or does not currently relay from vehicle (TWC3)
|
||
if !isCg || errors.Is(err, api.ErrNotAvailable) {
|
||
// validate if current too high by more than 1A (https://github.com/evcc-io/evcc/issues/14731)
|
||
if current := lp.GetMaxPhaseCurrent(); current > lp.offeredCurrent+1.0 {
|
||
if shouldBeConsistent && !lp.chargerHasFeature(api.Heating) {
|
||
lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA measured, expected %.3gA) - make sure your interval is at least 30s", current, lp.offeredCurrent)
|
||
}
|
||
lp.offeredCurrent = current
|
||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||
}
|
||
}
|
||
|
||
// sync phases
|
||
if shouldBeConsistent {
|
||
if err := lp.syncChargerPhases(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
case enabled == lp.enabled:
|
||
// sync disabled state
|
||
|
||
case !enabled && !lp.phaseSwitchCompleted():
|
||
// some chargers (i.E. Easee in some configurations) disable themselves to be able to switch phases
|
||
// -> enable charger
|
||
if err := lp.charger.Enable(true); err != nil {
|
||
return fmt.Errorf("charger enable: %w", err)
|
||
}
|
||
|
||
case shouldBeConsistent && (enabled || lp.connected()):
|
||
// ignore disabled state if vehicle was disconnected (!lp.enabled && !lp.connected)
|
||
lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// coarseCurrent returns true if charger or vehicle require full amp steps
|
||
func (lp *Loadpoint) coarseCurrent() bool {
|
||
return !api.HasCap[api.ChargerEx](lp.charger) || lp.vehicleHasFeature(api.CoarseCurrent)
|
||
}
|
||
|
||
// roundedCurrent rounds current down to full amps if charger or vehicle require it
|
||
func (lp *Loadpoint) roundedCurrent(current float64) float64 {
|
||
// full amps only?
|
||
if lp.coarseCurrent() {
|
||
current = math.Trunc(current)
|
||
}
|
||
return current
|
||
}
|
||
|
||
// actualMaxChargeCurrent returns the maximum of all phase currents.
|
||
// If currents not measured falls back to offered current.
|
||
func (lp *Loadpoint) actualMaxChargeCurrent() float64 {
|
||
if lp.chargeCurrents != nil {
|
||
return max(lp.chargeCurrents[0], lp.chargeCurrents[1], lp.chargeCurrents[2])
|
||
}
|
||
if lp.charging() {
|
||
return lp.offeredCurrent
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// setLimit applies charger current limits and enables/disables accordingly
|
||
func (lp *Loadpoint) setLimit(current float64) error {
|
||
current = lp.roundedCurrent(current)
|
||
|
||
// apply circuit limits
|
||
if lp.circuit != nil {
|
||
currentLimit := lp.circuit.ValidateCurrent(lp.actualMaxChargeCurrent(), current)
|
||
|
||
activePhases := lp.ActivePhases()
|
||
powerLimit := lp.circuit.ValidatePower(lp.chargePower, currentToPower(current, activePhases))
|
||
currentLimitViaPower := powerToCurrent(powerLimit, activePhases)
|
||
|
||
current = lp.roundedCurrent(min(currentLimit, currentLimitViaPower))
|
||
}
|
||
|
||
// https://github.com/evcc-io/evcc/issues/16309
|
||
effMinCurrent := lp.effectiveMinCurrent()
|
||
if effMaxCurrent := lp.effectiveMaxCurrent(); effMinCurrent > effMaxCurrent {
|
||
return fmt.Errorf("invalid config: min current %.3gA exceeds max current %.3gA", effMinCurrent, effMaxCurrent)
|
||
}
|
||
|
||
// set current
|
||
if current != lp.offeredCurrent && current >= effMinCurrent {
|
||
var err error
|
||
if charger, ok := api.Cap[api.ChargerEx](lp.charger); ok {
|
||
err = charger.MaxCurrentMillis(current)
|
||
} else {
|
||
err = lp.charger.MaxCurrent(int64(current))
|
||
}
|
||
|
||
if err != nil {
|
||
v := lp.GetVehicle()
|
||
if vv, ok := api.Cap[api.Resurrector](v); ok && errors.Is(err, api.ErrAsleep) && !hasFeature(v, api.WakeUpDisabled) {
|
||
// https://github.com/evcc-io/evcc/issues/8254
|
||
// wakeup vehicle
|
||
lp.log.DEBUG.Printf("set charge current limit: waking up vehicle")
|
||
if err := vv.WakeUp(); err != nil {
|
||
return fmt.Errorf("wake-up vehicle: %w", err)
|
||
}
|
||
}
|
||
|
||
return fmt.Errorf("set charge current limit %.3gA: %w", current, err)
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("set charge current limit: %.3gA", current)
|
||
lp.offeredCurrent = current
|
||
lp.bus.Publish(evChargeCurrent, current)
|
||
}
|
||
|
||
// set enabled/disabled
|
||
if enabled := current >= effMinCurrent; enabled != lp.enabled {
|
||
if err := lp.charger.Enable(enabled); err != nil {
|
||
v := lp.GetVehicle()
|
||
if vv, ok := api.Cap[api.Resurrector](v); enabled && ok && errors.Is(err, api.ErrAsleep) && !hasFeature(v, api.WakeUpDisabled) {
|
||
// https://github.com/evcc-io/evcc/issues/8254
|
||
// wakeup vehicle
|
||
lp.log.DEBUG.Printf("charger %s: waking up vehicle", status[enabled])
|
||
if err := vv.WakeUp(); err != nil {
|
||
return fmt.Errorf("wake-up vehicle: %w", err)
|
||
}
|
||
}
|
||
|
||
return fmt.Errorf("charger %s: %w", status[enabled], err)
|
||
}
|
||
|
||
lp.setAndPublishEnabled(enabled)
|
||
lp.chargerSwitched = lp.clock.Now()
|
||
|
||
// ensure we always re-set current when enabling charger
|
||
if !enabled {
|
||
lp.offeredCurrent = 0
|
||
}
|
||
|
||
lp.bus.Publish(evChargeCurrent, current)
|
||
|
||
// start/stop vehicle wake-up timer
|
||
if enabled {
|
||
lp.startWakeUpTimer()
|
||
} else {
|
||
lp.stopWakeUpTimer()
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// connected returns the EVs connection state
|
||
func (lp *Loadpoint) connected() bool {
|
||
status := lp.GetStatus()
|
||
return status == api.StatusB || status == api.StatusC
|
||
}
|
||
|
||
// charging returns the EVs charging state
|
||
func (lp *Loadpoint) charging() bool {
|
||
return lp.GetStatus() == api.StatusC
|
||
}
|
||
|
||
// PvChargeStarting reports a PV loadpoint that claimed surplus via a running
|
||
// enable timer but is not yet drawing it and has not reached its goal. See #31194, #31684.
|
||
func (lp *Loadpoint) PvChargeStarting() bool {
|
||
lp.RLock()
|
||
enabled := lp.enabled
|
||
pvTimerRunning := !lp.pvTimer.IsZero()
|
||
lp.RUnlock()
|
||
|
||
if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) {
|
||
return false
|
||
}
|
||
|
||
// enable timer running (not yet enabled)
|
||
return !enabled && pvTimerRunning
|
||
}
|
||
|
||
// chargeGoalReached reports whether the loadpoint will not draw more: enabled
|
||
// but not charging, an energy limit reached, or soc at/above the limit (#31684).
|
||
func (lp *Loadpoint) chargeGoalReached(enabled bool) bool {
|
||
// enabled but drawing nothing: it won't ramp up
|
||
if enabled && !lp.charging() {
|
||
return true
|
||
}
|
||
|
||
soc := lp.GetSoc()
|
||
return lp.LimitEnergyReached() || (soc > 0 && soc >= float64(lp.EffectiveLimitSoc()))
|
||
}
|
||
|
||
// setStatus updates the internal charging state according to EV
|
||
func (lp *Loadpoint) setStatus(status api.ChargeStatus) {
|
||
lp.Lock()
|
||
defer lp.Unlock()
|
||
lp.status = status
|
||
}
|
||
|
||
// socBasedPlanning returns true if vehicle soc (optionally from charger) and capacity are available
|
||
func (lp *Loadpoint) socBasedPlanning() bool {
|
||
v := lp.GetVehicle()
|
||
return (v != nil && v.Capacity() > 0) && (lp.vehicleHasSoc() || lp.vehicleSoc > 0)
|
||
}
|
||
|
||
// repeatingPlanning returns true if the current plan is a repeating plan
|
||
func (lp *Loadpoint) repeatingPlanning() bool {
|
||
if !lp.socBasedPlanning() {
|
||
return false
|
||
}
|
||
return lp.getPlanId() > 1
|
||
}
|
||
|
||
// vehicleHasSoc returns true if active vehicle supports returning soc, i.e. it is not an offline vehicle
|
||
func (lp *Loadpoint) vehicleHasSoc() bool {
|
||
return lp.GetVehicle() != nil && !lp.vehicleHasFeature(api.Offline)
|
||
}
|
||
|
||
// remainingLimitEnergy returns missing energy amount in kWh if vehicle has a valid energy target
|
||
func (lp *Loadpoint) remainingLimitEnergy() (float64, bool) {
|
||
limit := lp.getLimitEnergy()
|
||
return max(0, limit-lp.getChargedEnergy()/1e3),
|
||
limit > 0 && !lp.socBasedPlanning()
|
||
}
|
||
|
||
// LimitEnergyReached checks if target is configured and reached
|
||
func (lp *Loadpoint) LimitEnergyReached() bool {
|
||
lp.RLock()
|
||
defer lp.RUnlock()
|
||
f, ok := lp.remainingLimitEnergy()
|
||
return ok && f <= 0
|
||
}
|
||
|
||
// LimitSocReached returns true if the effective limit has been reached
|
||
func (lp *Loadpoint) LimitSocReached() bool {
|
||
lp.RLock()
|
||
defer lp.RUnlock()
|
||
limit := lp.effectiveLimitSoc()
|
||
return limit > 0 && limit < 100 && lp.vehicleSoc >= float64(limit)
|
||
}
|
||
|
||
// minSocNotReached checks if minimum is configured and not reached
|
||
func (lp *Loadpoint) minSocNotReached() bool {
|
||
minSoc := lp.effectiveMinSoc()
|
||
if minSoc == 0 {
|
||
return false
|
||
}
|
||
|
||
if lp.vehicleSoc != 0 {
|
||
active := lp.vehicleSoc < float64(minSoc)
|
||
if active {
|
||
lp.log.DEBUG.Printf("forced charging at vehicle soc %.0f%% (< %.0f%% min soc)", lp.vehicleSoc, float64(minSoc))
|
||
}
|
||
return active
|
||
}
|
||
|
||
v := lp.GetVehicle()
|
||
if v == nil {
|
||
return false
|
||
}
|
||
|
||
minEnergy := v.Capacity() * float64(minSoc) * 10 / soc.ChargeEfficiency
|
||
return minEnergy > 0 && lp.getChargedEnergy() < minEnergy
|
||
}
|
||
|
||
// disableUnlessClimater disables the charger unless climate is active
|
||
func (lp *Loadpoint) disableUnlessClimater() error {
|
||
var current float64 // zero disables
|
||
if lp.vehicleClimateActive() {
|
||
current = lp.effectiveMinCurrent()
|
||
}
|
||
|
||
return lp.setLimit(current)
|
||
}
|
||
|
||
// statusEvents converts the observed charger status change into a logical sequence of events
|
||
func statusEvents(prevStatus, status api.ChargeStatus) []string {
|
||
res := make([]string, 0, 2)
|
||
|
||
// changed from A - connected
|
||
if prevStatus == api.StatusA || (status != api.StatusA && prevStatus == api.StatusNone) {
|
||
res = append(res, evVehicleConnect)
|
||
}
|
||
|
||
// changed to C - start charging
|
||
if status == api.StatusC {
|
||
res = append(res, evChargeStart)
|
||
}
|
||
|
||
// changed from C - stop charging
|
||
if prevStatus == api.StatusC {
|
||
res = append(res, evChargeStop)
|
||
}
|
||
|
||
// changed to A - disconnected
|
||
if status == api.StatusA {
|
||
res = append(res, evVehicleDisconnect)
|
||
}
|
||
|
||
return res
|
||
}
|
||
|
||
// updateChargerStatus updates charger status and detects car connected/disconnected events
|
||
func (lp *Loadpoint) updateChargerStatus() (bool, error) {
|
||
statusChanges, err := lp.getStatusChanges()
|
||
if err != nil || len(statusChanges) == 0 {
|
||
return false, err
|
||
}
|
||
|
||
var welcomeCharge bool
|
||
|
||
for _, status := range statusChanges {
|
||
prevStatus := lp.GetStatus()
|
||
lp.setStatus(status)
|
||
|
||
for _, ev := range statusEvents(prevStatus, status) {
|
||
lp.bus.Publish(ev)
|
||
|
||
// send connect/disconnect events except during startup
|
||
if prevStatus != api.StatusNone {
|
||
switch ev {
|
||
case evVehicleConnect:
|
||
// defer notification until vehicle detection settles
|
||
lp.connectPending = true
|
||
welcomeCharge = lp.needsWelcomeCharge()
|
||
case evVehicleDisconnect:
|
||
// a still-pending connect means the vehicle disconnected before
|
||
// its notification was confirmed; omit both connect and disconnect
|
||
if lp.connectPending {
|
||
lp.connectPending = false
|
||
} else {
|
||
lp.pushEvent(evVehicleDisconnect)
|
||
}
|
||
welcomeCharge = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// update whenever there is a state change
|
||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||
|
||
return welcomeCharge, nil
|
||
}
|
||
|
||
// getStatusChanges checks charger status and returns a chronological list of status changes
|
||
func (lp *Loadpoint) getStatusChanges() ([]api.ChargeStatus, error) {
|
||
var res []api.ChargeStatus
|
||
|
||
status, err := lp.charger.Status()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("charger status: %w", err)
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("charger status: %s", status)
|
||
|
||
// detect if charger status changed
|
||
prevStatus := lp.GetStatus()
|
||
if status != prevStatus {
|
||
res = []api.ChargeStatus{status}
|
||
}
|
||
|
||
// check charger connection duration
|
||
if ct, ok := api.Cap[api.ConnectionTimer](lp.charger); ok {
|
||
d, err := ct.ConnectionDuration()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("connection duration: %w", err)
|
||
}
|
||
|
||
defer func() { lp.connectedDuration = d }()
|
||
|
||
// connection duration dropped without disconnect status, indicates intermediate disconnect
|
||
if status != api.StatusA && prevStatus != api.StatusA && d < lp.connectedDuration {
|
||
lp.log.DEBUG.Printf("connection duration drop detected (%s -> %v)", lp.connectedDuration.Round(time.Second), d.Round(time.Second))
|
||
res = []api.ChargeStatus{api.StatusA, status}
|
||
}
|
||
}
|
||
|
||
return res, nil
|
||
}
|
||
|
||
// needsWelcomeCharge checks if either the charger or a vehicle requires a welcome charge
|
||
func (lp *Loadpoint) needsWelcomeCharge() bool {
|
||
if lp.chargerHasFeature(api.WelcomeCharge) || hasFeature(lp.defaultVehicle, api.WelcomeCharge) {
|
||
return true
|
||
}
|
||
|
||
// Enable charging on connect if any available vehicle requires it.
|
||
// We're using the PV timer to disable after the welcome
|
||
if !lp.chargerHasFeature(api.IntegratedDevice) {
|
||
for _, v := range lp.availableVehicles() {
|
||
if slices.Contains(v.Features(), api.WelcomeCharge) {
|
||
lp.log.DEBUG.Printf("welcome charge: %s", v.GetTitle())
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// effectiveCurrent returns the currently effective charging current
|
||
func (lp *Loadpoint) effectiveCurrent() float64 {
|
||
if !lp.charging() {
|
||
return 0
|
||
}
|
||
|
||
// adjust actual current for vehicles like Zoe where it remains below target
|
||
if lp.chargeCurrents != nil {
|
||
cur := max(lp.chargeCurrents[0], lp.chargeCurrents[1], lp.chargeCurrents[2])
|
||
return min(cur+2.0, lp.offeredCurrent)
|
||
}
|
||
|
||
return lp.offeredCurrent
|
||
}
|
||
|
||
// elapsePVTimer puts the pv enable/disable timer into elapsed state
|
||
func (lp *Loadpoint) elapsePVTimer() {
|
||
if lp.pvTimer.Equal(elapsed) {
|
||
return
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("pv timer elapse")
|
||
|
||
lp.pvTimer = elapsed
|
||
lp.publishTimer(pvTimer, 0, timerInactive)
|
||
}
|
||
|
||
// resetPVTimer resets the pv enable/disable timer to disabled state
|
||
func (lp *Loadpoint) resetPVTimer(typ ...string) {
|
||
if lp.pvTimer.IsZero() {
|
||
return
|
||
}
|
||
|
||
msg := "pv timer reset"
|
||
if len(typ) == 1 {
|
||
msg = fmt.Sprintf("pv %s timer reset", typ[0])
|
||
}
|
||
lp.log.DEBUG.Println(msg)
|
||
|
||
lp.pvTimer = time.Time{}
|
||
lp.publishTimer(pvTimer, 0, timerInactive)
|
||
}
|
||
|
||
// resetPhaseTimer resets the phase switch timer to disabled state
|
||
func (lp *Loadpoint) resetPhaseTimer() {
|
||
if lp.phaseTimer.IsZero() {
|
||
return
|
||
}
|
||
|
||
lp.phaseTimer = time.Time{}
|
||
lp.publishTimer(phaseTimer, 0, timerInactive)
|
||
}
|
||
|
||
// scalePhasesRequired validates if fixed phase configuration matches enabled phases
|
||
func (lp *Loadpoint) scalePhasesRequired() bool {
|
||
return lp.hasPhaseSwitching() && lp.phasesConfigured != 0 && lp.phasesConfigured != lp.GetPhases()
|
||
}
|
||
|
||
// scalePhasesIfAvailable scales if api.PhaseSwitcher is available and allowed
|
||
func (lp *Loadpoint) scalePhasesIfAvailable(phases int) error {
|
||
if lp.phasesConfigured != 0 {
|
||
phases = lp.phasesConfigured
|
||
}
|
||
|
||
if lp.hasPhaseSwitching() {
|
||
return lp.scalePhases(phases)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// scalePhases adjusts the number of active phases and returns the appropriate charging current.
|
||
// Returns api.ErrNotAvailable if api.PhaseSwitcher is not available.
|
||
func (lp *Loadpoint) scalePhases(phases int) error {
|
||
cp, ok := api.Cap[api.PhaseSwitcher](lp.charger)
|
||
if !ok {
|
||
panic("charger does not implement api.PhaseSwitcher")
|
||
}
|
||
|
||
if lp.GetPhases() != phases {
|
||
// switch phases
|
||
if err := cp.Phases1p3p(phases); err != nil {
|
||
return fmt.Errorf("switch phases: %w", err)
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("switched phases: %dp", phases)
|
||
|
||
// prevent premature measurement of active phases
|
||
lp.phasesSwitched = lp.clock.Now()
|
||
|
||
// update setting and reset timer
|
||
lp.SetPhases(phases)
|
||
|
||
// some vehicles may hang on phase switch
|
||
lp.startWakeUpTimer()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// circuitAllowsPhases checks if the circuit power limit allows charging at minCurrent on phases
|
||
func (lp *Loadpoint) circuitAllowsPhases(phases int, minCurrent float64) bool {
|
||
if lp.circuit == nil {
|
||
return true
|
||
}
|
||
|
||
minPower := currentToPower(minCurrent, phases)
|
||
powerLimit := lp.circuit.ValidatePower(lp.chargePower, minPower)
|
||
if powerLimit < minPower {
|
||
lp.log.DEBUG.Printf("available circuit power %.0fW < %.0fW min %dp power", powerLimit, minPower, phases)
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// fastCharging scales to 3p if available and sets maximum current
|
||
func (lp *Loadpoint) fastCharging() error {
|
||
if lp.hasPhaseSwitching() {
|
||
phases := 3
|
||
|
||
// load management limit active
|
||
if !lp.circuitAllowsPhases(3, lp.effectiveMinCurrent()) {
|
||
phases = 1
|
||
}
|
||
|
||
// ignore api.ErrNotAvailable: the phase switch could not be performed
|
||
// right now, continue with the current phase configuration
|
||
if err := lp.scalePhasesIfAvailable(phases); err != nil && !errors.Is(err, api.ErrNotAvailable) {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return lp.setLimit(lp.effectiveMaxCurrent())
|
||
}
|
||
|
||
// minCharging scales to 1p if available and sets minimum current
|
||
func (lp *Loadpoint) minCharging() error {
|
||
if lp.hasPhaseSwitching() {
|
||
// ignore api.ErrNotAvailable: the phase switch could not be performed
|
||
// right now, continue with the current phase configuration
|
||
if err := lp.scalePhasesIfAvailable(1); err != nil && !errors.Is(err, api.ErrNotAvailable) {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return lp.setLimit(lp.effectiveMinCurrent())
|
||
}
|
||
|
||
// pvScalePhases switches phases if necessary and returns number of phases switched to
|
||
func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) int {
|
||
phases := lp.GetPhases()
|
||
|
||
// observed phase state inconsistency
|
||
// - https://github.com/evcc-io/evcc/issues/1572
|
||
// - https://github.com/evcc-io/evcc/issues/2230
|
||
// - https://github.com/evcc-io/evcc/issues/2613
|
||
measuredPhases := lp.GetMeasuredPhases()
|
||
if phases > 0 && phases < measuredPhases {
|
||
if lp.chargerUpdateCompleted() && lp.phaseSwitchCompleted() {
|
||
lp.log.WARN.Printf("ignoring inconsistent phases: %dp < %dp observed active", phases, measuredPhases)
|
||
}
|
||
lp.ResetMeasuredPhases()
|
||
}
|
||
|
||
var waiting bool
|
||
activePhases := lp.ActivePhases()
|
||
availablePower := lp.chargePower - sitePower
|
||
scalable := activePhases > 1 && lp.phasesConfigured < 3
|
||
|
||
if scalable {
|
||
insufficient := (sitePower > 0 || !lp.enabled) && powerToCurrent(availablePower, activePhases) < minCurrent
|
||
if insufficient {
|
||
lp.log.DEBUG.Printf("available power %.0fW < %.0fW min %dp threshold", availablePower, float64(activePhases)*Voltage*minCurrent, activePhases)
|
||
}
|
||
|
||
// while charging, scaling down only helps if 1p is sustainable, otherwise it
|
||
// merely delays the pv disable timer by the phase timer duration
|
||
useful := !lp.enabled || !lp.charging() || powerToCurrent(availablePower, 1) >= minCurrent
|
||
if insufficient && !useful {
|
||
lp.log.DEBUG.Printf("available power %.0fW < %.0fW min 1p threshold, disabling instead of scaling down", availablePower, Voltage*minCurrent)
|
||
}
|
||
|
||
// scaling down also frees load management headroom for min power on activePhases
|
||
scalable = insufficient && useful || !lp.circuitAllowsPhases(activePhases, minCurrent)
|
||
}
|
||
|
||
// scale down phases
|
||
if scalable {
|
||
if !lp.charging() { // scale immediately if not charging
|
||
lp.phaseTimer = elapsed
|
||
}
|
||
|
||
if lp.phaseTimer.IsZero() {
|
||
lp.log.DEBUG.Printf("start phase %s timer", phaseScale1p)
|
||
lp.phaseTimer = lp.clock.Now()
|
||
}
|
||
|
||
lp.publishTimer(phaseTimer, lp.GetDisableDelay(), phaseScale1p)
|
||
|
||
if elapsed := lp.clock.Since(lp.phaseTimer); elapsed >= lp.GetDisableDelay() {
|
||
if err := lp.scalePhases(1); err != nil {
|
||
// a charger may report it cannot switch phases right now
|
||
// (api.ErrNotAvailable); assume a failed switch and stay silent
|
||
if !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Println(err)
|
||
}
|
||
// switch did not complete - phase count is unchanged
|
||
return phases
|
||
}
|
||
return 1
|
||
}
|
||
|
||
waiting = true
|
||
}
|
||
|
||
// load management may cap the 1p current far below the theoretical maximum
|
||
if lp.circuit != nil {
|
||
maxCurrent = lp.circuit.ValidateCurrent(lp.actualMaxChargeCurrent(), maxCurrent)
|
||
}
|
||
|
||
maxPhases := lp.MaxActivePhases()
|
||
target1pCurrent := powerToCurrent(availablePower, 1)
|
||
|
||
// scaling up is pointless unless load management allows min current and power on maxPhases
|
||
scalable = maxPhases > 1 && phases < maxPhases && target1pCurrent > maxCurrent &&
|
||
maxCurrent >= minCurrent && lp.circuitAllowsPhases(maxPhases, minCurrent)
|
||
|
||
// scale up phases
|
||
if targetCurrent := powerToCurrent(availablePower, maxPhases); targetCurrent >= minCurrent && scalable {
|
||
lp.log.DEBUG.Printf("available power %.0fW > %.0fW min %dp threshold", availablePower, float64(maxPhases)*Voltage*minCurrent, maxPhases)
|
||
|
||
if !lp.charging() { // scale immediately if not charging
|
||
lp.phaseTimer = elapsed
|
||
}
|
||
|
||
if lp.phaseTimer.IsZero() {
|
||
lp.log.DEBUG.Printf("start phase %s timer", phaseScale3p)
|
||
lp.phaseTimer = lp.clock.Now()
|
||
}
|
||
|
||
lp.publishTimer(phaseTimer, lp.GetEnableDelay(), phaseScale3p)
|
||
|
||
if elapsed := lp.clock.Since(lp.phaseTimer); elapsed >= lp.GetEnableDelay() {
|
||
if err := lp.scalePhases(3); err != nil {
|
||
// a charger may report it cannot switch phases right now
|
||
// (api.ErrNotAvailable); assume a failed switch and stay silent
|
||
if !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Println(err)
|
||
}
|
||
// switch did not complete - phase count is unchanged
|
||
return phases
|
||
}
|
||
return 3
|
||
}
|
||
|
||
waiting = true
|
||
}
|
||
|
||
// reset timer to disabled state
|
||
if !waiting && !lp.phaseTimer.IsZero() {
|
||
lp.resetPhaseTimer()
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// TODO move up to timer functions
|
||
func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action string) {
|
||
timer := lp.pvTimer
|
||
if name == phaseTimer {
|
||
timer = lp.phaseTimer
|
||
}
|
||
|
||
remaining := max(delay-lp.clock.Since(timer), 0)
|
||
|
||
lp.publish(name+"Action", action)
|
||
lp.publish(name+"Remaining", remaining)
|
||
|
||
if action == timerInactive {
|
||
lp.log.DEBUG.Printf("%s timer %s", name, action)
|
||
} else {
|
||
lp.log.DEBUG.Printf("%s %s in %v", name, action, remaining.Round(time.Second))
|
||
}
|
||
}
|
||
|
||
// boostPower returns the additional power that the loadpoint should draw from the battery
|
||
func (lp *Loadpoint) boostPower(batteryPower float64) float64 {
|
||
boost := lp.GetBatteryBoost()
|
||
if boost == boostDisabled || boost == boostHold {
|
||
return 0
|
||
}
|
||
|
||
// push demand to drain battery (at least 100W)
|
||
delta := math.Max(100, math.Abs(lp.site.GetResidualPower()))
|
||
|
||
if lp.coarseCurrent() {
|
||
// add effective step power to delta to make sure to step up to the next full amp
|
||
// just using lp.EffectiveStepPower() as delta is not enough because this will result
|
||
// in a too low current when there is a bit remaining grid consumption due to the accuracy
|
||
// of the battery controller
|
||
delta += lp.EffectiveStepPower()
|
||
}
|
||
|
||
// bridge the power gap between 1p max and 3p min so pvScalePhases can trigger a scale-up
|
||
if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() && lp.site.GetBatteryMaxDischargePower() != nil {
|
||
if activePhases, maxPhases := lp.ActivePhases(), lp.MaxActivePhases(); activePhases < maxPhases &&
|
||
lp.circuitAllowsPhases(maxPhases, lp.effectiveMinCurrent()) {
|
||
// max power actually achievable on the active phases
|
||
activeMaxPower := min(lp.EffectiveMaxPower(), Voltage*lp.effectiveMaxCurrent()*float64(activePhases))
|
||
delta += max(0, lp.EffectiveMinPower()*float64(maxPhases)-activeMaxPower)
|
||
}
|
||
}
|
||
|
||
// start boosting by setting maximum power
|
||
if boost == boostStart {
|
||
delta = lp.EffectiveMaxPower()
|
||
|
||
// expire timers
|
||
if lp.hasPhaseSwitching() {
|
||
lp.phaseTimer = elapsed
|
||
}
|
||
lp.pvTimer = elapsed
|
||
|
||
if lp.charging() {
|
||
lp.setBatteryBoost(boostContinue)
|
||
}
|
||
}
|
||
|
||
if maxDischargePower := lp.site.GetBatteryMaxDischargePower(); maxDischargePower != nil {
|
||
// limit delta to what the battery can still provide
|
||
delta = min(delta, max(0, *maxDischargePower-batteryPower))
|
||
}
|
||
|
||
res := max(0, batteryPower) + delta + lp.site.GetResidualPower()
|
||
lp.log.DEBUG.Printf("pv charge battery boost: %.0fW = -%.0fW battery - %.0fW boost - %.0fW residual", -res, max(0, batteryPower), delta, lp.site.GetResidualPower())
|
||
|
||
return res
|
||
}
|
||
|
||
// pvMaxCurrent calculates the maximum target current for PV mode
|
||
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryPower float64, batteryBuffered, batteryStart bool) float64 {
|
||
// read only once to simplify testing
|
||
minCurrent := lp.effectiveMinCurrent()
|
||
maxCurrent := lp.effectiveMaxCurrent()
|
||
|
||
// push demand to drain battery
|
||
sitePower -= lp.boostPower(batteryPower)
|
||
|
||
// switch phases up/down
|
||
var scaledTo int
|
||
if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() {
|
||
scaledTo = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
|
||
}
|
||
|
||
// calculate target charge current from delta power and actual current
|
||
activePhases := lp.ActivePhases()
|
||
effectiveCurrent := lp.effectiveCurrent()
|
||
if scaledTo == 3 {
|
||
// if we did scale, adjust the effective current to the new phase count
|
||
effectiveCurrent /= float64(lp.maxActivePhases())
|
||
}
|
||
if lp.chargerHasFeature(api.IntegratedDevice) {
|
||
// for slow-acting heating devices, only take actually consumed power into account
|
||
effectiveCurrent = powerToCurrent(lp.chargePower, activePhases)
|
||
}
|
||
deltaCurrent := powerToCurrent(-sitePower, activePhases)
|
||
targetCurrent := max(effectiveCurrent+deltaCurrent, 0)
|
||
|
||
// in MinPV mode or under special conditions return at least minCurrent
|
||
if battery := batteryStart || batteryBuffered && lp.charging() || lp.GetBatteryBoost() == boostContinue; (mode == api.ModeMinPV || battery) && targetCurrent < minCurrent {
|
||
lp.log.DEBUG.Printf("pv charge current: min %.3gA > %.3gA (%.0fW @ %dp, battery: %t)", minCurrent, targetCurrent, sitePower, activePhases, battery)
|
||
return minCurrent
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("pv charge current: %.3gA = %.3gA + %.3gA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, activePhases)
|
||
|
||
if mode == api.ModePV && lp.enabled && targetCurrent < minCurrent {
|
||
projectedSitePower := sitePower
|
||
if lp.hasPhaseSwitching() && !lp.phaseTimer.IsZero() {
|
||
// calculate site power after a phase switch from activePhases phases -> 1 phase
|
||
// notes: activePhases can be 1, 2 or 3 and phaseTimer can only be active if lp current is already at minCurrent
|
||
projectedSitePower -= Voltage * minCurrent * float64(activePhases-1)
|
||
}
|
||
// kick off disable sequence, unless climater keep-alive is holding
|
||
// charging at minCurrent — otherwise the "pausing soon" badge would
|
||
// flash on/off forever while climater is active (issue #29834).
|
||
if projectedSitePower >= lp.Disable.Threshold && !lp.vehicleClimateActive() {
|
||
lp.log.DEBUG.Printf("projected site power %.0fW >= %.0fW disable threshold", projectedSitePower, lp.Disable.Threshold)
|
||
|
||
if lp.pvTimer.IsZero() {
|
||
lp.log.DEBUG.Printf("pv disable timer start: %v", lp.GetDisableDelay())
|
||
lp.pvTimer = lp.clock.Now()
|
||
}
|
||
|
||
lp.publishTimer(pvTimer, lp.GetDisableDelay(), pvDisable)
|
||
|
||
elapsed := lp.clock.Since(lp.pvTimer)
|
||
if elapsed >= lp.GetDisableDelay() {
|
||
lp.log.DEBUG.Println("pv disable timer elapsed")
|
||
|
||
// reset timer to prevent immediate charger re-enabling
|
||
lp.resetPVTimer()
|
||
|
||
return 0
|
||
}
|
||
|
||
// suppress duplicate log message after timer started
|
||
if elapsed > time.Second {
|
||
lp.log.DEBUG.Printf("pv disable timer remaining: %v", (lp.GetDisableDelay() - elapsed).Round(time.Second))
|
||
}
|
||
} else {
|
||
// reset timer
|
||
lp.resetPVTimer("disable")
|
||
}
|
||
|
||
// lp.log.DEBUG.Println("pv disable timer: keep enabled")
|
||
return minCurrent
|
||
}
|
||
|
||
if mode == api.ModePV && !lp.enabled {
|
||
// kick off enable sequence
|
||
if (lp.Enable.Threshold == 0 && targetCurrent >= minCurrent) ||
|
||
(lp.Enable.Threshold != 0 && sitePower <= lp.Enable.Threshold) {
|
||
lp.log.DEBUG.Printf("site power %.0fW <= %.0fW enable threshold", sitePower, lp.Enable.Threshold)
|
||
|
||
if lp.pvTimer.IsZero() {
|
||
lp.log.DEBUG.Printf("pv enable timer start: %v", lp.GetEnableDelay())
|
||
lp.pvTimer = lp.clock.Now()
|
||
}
|
||
|
||
lp.publishTimer(pvTimer, lp.GetEnableDelay(), pvEnable)
|
||
|
||
elapsed := lp.clock.Since(lp.pvTimer)
|
||
if elapsed >= lp.GetEnableDelay() {
|
||
lp.log.DEBUG.Println("pv enable timer elapsed")
|
||
|
||
// reset timer to prevent immediate charger re-disabling
|
||
lp.resetPVTimer()
|
||
|
||
return minCurrent
|
||
}
|
||
|
||
// suppress duplicate log message after timer started
|
||
if elapsed > time.Second {
|
||
lp.log.DEBUG.Printf("pv enable timer remaining: %v", (lp.GetEnableDelay() - elapsed).Round(time.Second))
|
||
}
|
||
} else {
|
||
// reset timer
|
||
lp.resetPVTimer("enable")
|
||
}
|
||
|
||
// lp.log.DEBUG.Println("pv enable timer: keep disabled")
|
||
return 0
|
||
}
|
||
|
||
// reset timer to disabled state
|
||
lp.resetPVTimer()
|
||
|
||
// cap at maximum current
|
||
targetCurrent = min(targetCurrent, maxCurrent)
|
||
|
||
return targetCurrent
|
||
}
|
||
|
||
// UpdateChargePowerAndCurrents updates charge meter power and currents for load management
|
||
func (lp *Loadpoint) UpdateChargePowerAndCurrents() float64 {
|
||
power, err := backoff.RetryWithData(lp.chargeMeter.CurrentPower, modbus.Backoff())
|
||
if err == nil {
|
||
lp.Lock()
|
||
lp.chargePower = power // update value if no error
|
||
lp.Unlock()
|
||
|
||
lp.log.DEBUG.Printf("charge power: %.0fW", power)
|
||
lp.publish(keys.ChargePower, power)
|
||
|
||
// https://github.com/evcc-io/evcc/issues/2153
|
||
// https://github.com/evcc-io/evcc/issues/6986
|
||
// https://github.com/evcc-io/evcc/issues/13378
|
||
if power < -100 && lp.shouldBeConsistent() {
|
||
lp.log.WARN.Printf("charge power must not be negative: %.0f", power)
|
||
}
|
||
} else {
|
||
power = 0
|
||
lp.log.ERROR.Printf("charge power: %v", err)
|
||
}
|
||
|
||
// update charge currents
|
||
lp.chargeCurrents = nil
|
||
|
||
if phaseMeter, ok := api.Cap[api.PhaseCurrents](lp.chargeMeter); ok {
|
||
if err := backoff.Retry(func() error {
|
||
i1, i2, i3, err := phaseMeter.Currents()
|
||
if err != nil {
|
||
if errors.Is(err, api.ErrNotAvailable) {
|
||
err = backoff.Permanent(err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
lp.Lock()
|
||
lp.chargeCurrents = []float64{i1, i2, i3}
|
||
lp.Unlock()
|
||
|
||
lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents)
|
||
lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
|
||
|
||
return nil
|
||
}, modbus.Backoff()); err != nil && !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Printf("charge currents: %v", err)
|
||
}
|
||
}
|
||
|
||
return power
|
||
}
|
||
|
||
// phasesFromChargeCurrents uses PhaseCurrents interface to count phases with current >=1A
|
||
func (lp *Loadpoint) phasesFromChargeCurrents() {
|
||
if lp.chargeCurrents == nil {
|
||
return
|
||
}
|
||
|
||
if lp.charging() && lp.phaseSwitchCompleted() {
|
||
var phases int
|
||
for _, i := range lp.chargeCurrents {
|
||
if i > minActiveCurrent {
|
||
phases++
|
||
}
|
||
}
|
||
|
||
if phases >= 1 {
|
||
lp.Lock()
|
||
lp.measuredPhases = phases
|
||
lp.Unlock()
|
||
|
||
lp.log.DEBUG.Printf("detected active phases: %dp", phases)
|
||
lp.publish(keys.PhasesActive, phases)
|
||
}
|
||
}
|
||
}
|
||
|
||
// updateChargeVoltages uses PhaseVoltages interface to count phases with nominal grid voltage
|
||
func (lp *Loadpoint) updateChargeVoltages() {
|
||
phaseMeter, ok := api.Cap[api.PhaseVoltages](lp.chargeMeter)
|
||
if !ok {
|
||
return // don't guess
|
||
}
|
||
|
||
u1, u2, u3, err := phaseMeter.Voltages()
|
||
if err != nil {
|
||
// phaseSwitching devices may announce voltages but doesn't deliver
|
||
if !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Printf("charge voltages: %v", err)
|
||
}
|
||
return
|
||
}
|
||
|
||
chargeVoltages := []float64{u1, u2, u3}
|
||
lp.log.DEBUG.Printf("charge voltages: %.3gV", chargeVoltages)
|
||
lp.publish(keys.ChargeVoltages, chargeVoltages)
|
||
|
||
if lp.hasPhaseSwitching() {
|
||
return // we don't need the voltages, but publish
|
||
}
|
||
|
||
a1, a2, a3 := u1 >= minActiveVoltage, u2 >= minActiveVoltage, u3 >= minActiveVoltage
|
||
|
||
// Quine-McCluskey for (¬L1∧L2∧¬L3) ∨ (L1∧L2∧¬L3) ∨ (¬L1∧¬L2∧L3) ∨ (L1∧¬L2∧L3) ∨ (¬L1∧L2∧L3) -> ¬L1 ∧ L3 ∨ L2 ∧ ¬L3 ∨ ¬L2 ∧ L3
|
||
if !a1 && a3 || a2 && !a3 || !a2 && a3 {
|
||
lp.log.WARN.Printf("invalid phase wiring between charge meter and charger")
|
||
}
|
||
|
||
var phases int
|
||
if a1 || a2 || a3 {
|
||
phases = 3
|
||
}
|
||
if a1 && !a2 && !a3 {
|
||
phases = 1
|
||
}
|
||
|
||
if phases >= 1 {
|
||
lp.log.DEBUG.Printf("detected connected phases: %dp", phases)
|
||
lp.SetPhases(phases)
|
||
}
|
||
}
|
||
|
||
// publish charged energy and duration
|
||
func (lp *Loadpoint) publishChargeProgress() {
|
||
if f, err := lp.chargeRater.ChargedEnergy(); err == nil {
|
||
// workaround for Go-E resetting during disconnect, see
|
||
// https://github.com/evcc-io/evcc/issues/5092
|
||
switch session := f - lp.chargedAtStartup; {
|
||
case session > maxSessionEnergy:
|
||
// guard against meters reporting register garbage, see
|
||
// https://github.com/evcc-io/evcc/issues/32159
|
||
lp.log.WARN.Printf("ignoring implausible session energy: %.3fkWh", session)
|
||
|
||
case session > 0:
|
||
added, addedGreen := lp.energyMetrics.Update(session)
|
||
if added > 0 {
|
||
lp.log.DEBUG.Printf("session energy: %.3fkWh", f)
|
||
}
|
||
|
||
if telemetry.Enabled() && added > 0 {
|
||
telemetry.UpdateEnergy(added, addedGreen)
|
||
}
|
||
}
|
||
} else if !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Printf("charge rater: %v", err)
|
||
}
|
||
|
||
if d, err := lp.chargeTimer.ChargeDuration(); err == nil {
|
||
lp.chargeDuration = d.Round(time.Second)
|
||
} else if !errors.Is(err, api.ErrNotAvailable) {
|
||
lp.log.ERROR.Printf("charge timer: %v", err)
|
||
}
|
||
|
||
// TODO check if "session" prefix required?
|
||
lp.energyMetrics.Publish("session", lp)
|
||
|
||
// TODO deprecated: use sessionEnergy instead
|
||
lp.publish(keys.ChargedEnergy, lp.GetChargedEnergy())
|
||
lp.publish(keys.ChargeDuration, lp.chargeDuration)
|
||
|
||
// update energy, prefer totals
|
||
var importTotal *float64
|
||
if api.HasCap[api.MeterEnergy](lp.chargeMeter) {
|
||
if f := lp.chargeMeterTotal(); f > 0 {
|
||
lp.publish(keys.ChargeTotalImport, f)
|
||
importTotal = &f
|
||
}
|
||
}
|
||
|
||
if lp.chargeEnergy != nil {
|
||
lp.chargeEnergy.AddEnergy(importTotal, nil, lp.chargePower)
|
||
if v := lp.GetSoc(); v > 0 {
|
||
lp.chargeEnergy.SetSocTemp(v, lp.chargerHasFeature(api.Heating))
|
||
}
|
||
}
|
||
|
||
lp.publishEnergyStats()
|
||
}
|
||
|
||
// publishEnergyStats publishes time-based energy statistics in Wh
|
||
func (lp *Loadpoint) publishEnergyStats() {
|
||
if lp.chargeEnergy == nil {
|
||
return
|
||
}
|
||
|
||
stats, err := lp.chargeEnergy.EnergyStats()
|
||
if err != nil {
|
||
lp.log.ERROR.Printf("energy stats: %v", err)
|
||
return
|
||
}
|
||
|
||
lp.publish(keys.TodayEnergy, math.Round(stats.Today*1e3))
|
||
lp.publish(keys.Last24hEnergy, math.Round(stats.Last24h*1e3))
|
||
lp.publish(keys.Last7dEnergy, math.Round(stats.Last7d*1e3))
|
||
}
|
||
|
||
// publish state of charge, remaining charge duration and range
|
||
//
|
||
// - online vehicle connected: this allows estimating remaining energy/duration
|
||
// - either charger or vehicle provides soc
|
||
// - estimator is responsible for querying both
|
||
//
|
||
// - offline or no vehicle connected (e.g. integrated device): missing capacity, hence no estimate
|
||
// - charger may still provide soc
|
||
// - no estimator
|
||
func (lp *Loadpoint) publishSocAndRange() {
|
||
// guard for socEstimator removed by api and keep a local copy in order to avoid race conditions
|
||
// https://github.com/evcc-io/evcc/issues/16180
|
||
socEstimator := lp.socEstimator
|
||
|
||
socAndLimit := func(typ string, dev any) (*float64, *int64, error) {
|
||
var socR *float64
|
||
var limitR *int64
|
||
var socErr error
|
||
|
||
if battery, ok := api.Cap[api.Battery](dev); ok {
|
||
if soc, err := soc.Guard(battery.Soc()); err == nil {
|
||
socR = &soc
|
||
|
||
// don't publish here in case it needs be updated by the estimator
|
||
lp.log.DEBUG.Printf("%s soc: %.0f%%", typ, soc)
|
||
|
||
if socLimiter, ok := api.Cap[api.SocLimiter](dev); ok {
|
||
if limit, err := socLimiter.GetLimitSoc(); err == nil {
|
||
limitR = &limit
|
||
|
||
lp.log.DEBUG.Printf("%s soc limit: %d%%", typ, limit)
|
||
// https://github.com/evcc-io/evcc/issues/13349
|
||
lp.publish(keys.VehicleLimitSoc, float64(limit))
|
||
} else if !loadpoint.AcceptableError(err) {
|
||
lp.log.ERROR.Printf("%s soc limit: %v", typ, err)
|
||
}
|
||
}
|
||
} else {
|
||
socErr = err
|
||
|
||
if !loadpoint.AcceptableError(err) {
|
||
lp.log.ERROR.Printf("charger soc: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
return socR, limitR, socErr
|
||
}
|
||
|
||
socR, limitR, _ := socAndLimit("charger", lp.charger)
|
||
if socR == nil && (lp.vehicleSocPollAllowed() || lp.chargerHasFeature(api.IntegratedDevice)) {
|
||
var socErr error
|
||
socR, limitR, socErr = socAndLimit("vehicle", lp.GetVehicle())
|
||
|
||
// keep polling async vehicle APIs that return ErrMustRetry until a SoC
|
||
// arrives, instead of waiting for the next interval
|
||
if !errors.Is(socErr, api.ErrMustRetry) {
|
||
lp.socUpdated = lp.clock.Now()
|
||
}
|
||
|
||
// range
|
||
if vs, ok := api.Cap[api.VehicleRange](lp.GetVehicle()); ok {
|
||
if rng, err := vs.Range(); err == nil {
|
||
lp.log.DEBUG.Printf("vehicle range: %dkm", rng)
|
||
lp.vehicleRange = rng
|
||
lp.publish(keys.VehicleRange, rng)
|
||
} else if !loadpoint.AcceptableError(err) {
|
||
lp.log.ERROR.Printf("vehicle range: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
if socR != nil {
|
||
if socEstimator == nil {
|
||
lp.vehicleSoc = *socR
|
||
} else {
|
||
lp.vehicleSoc = socEstimator.Soc(socR, lp.GetChargedEnergy())
|
||
lp.log.DEBUG.Printf("vehicle soc (estimator): %.0f%%", lp.vehicleSoc)
|
||
}
|
||
}
|
||
lp.publish(keys.VehicleSoc, lp.vehicleSoc)
|
||
|
||
apiLimitSoc := 100
|
||
if limitR != nil {
|
||
apiLimitSoc = int(*limitR)
|
||
// https://github.com/evcc-io/evcc/issues/13349
|
||
lp.publish(keys.VehicleLimitSoc, float64(*limitR))
|
||
}
|
||
|
||
limitSoc := min(apiLimitSoc, lp.EffectiveLimitSoc())
|
||
v := lp.GetVehicle()
|
||
|
||
var d time.Duration
|
||
var e float64
|
||
switch {
|
||
case socEstimator != nil:
|
||
if lp.charging() {
|
||
d = socEstimator.RemainingChargeDuration(float64(limitSoc), lp.chargePower)
|
||
}
|
||
e = socEstimator.RemainingChargeEnergy(limitSoc)
|
||
case v != nil && v.Capacity() > 0 && lp.vehicleSoc > 0:
|
||
if lp.charging() {
|
||
d = soc.RemainingChargeDuration(float64(limitSoc), lp.chargePower, lp.vehicleSoc, v.Capacity())
|
||
}
|
||
e = soc.RemainingChargeEnergy(limitSoc, lp.vehicleSoc, v.Capacity())
|
||
}
|
||
lp.SetRemainingDuration(d)
|
||
lp.SetRemainingEnergy(e)
|
||
|
||
// trigger message after variables are updated
|
||
lp.bus.Publish(evVehicleSoc, lp.vehicleSoc)
|
||
}
|
||
|
||
// addTask adds a single task to the queue
|
||
func (lp *Loadpoint) addTask(task func()) {
|
||
// test guard
|
||
if lp.tasks != nil {
|
||
// don't add twice
|
||
if t, ok := lp.tasks.First(); ok &&
|
||
reflect.ValueOf(t).Pointer() == reflect.ValueOf(task).Pointer() {
|
||
return
|
||
}
|
||
lp.tasks.Enqueue(task)
|
||
}
|
||
}
|
||
|
||
// processTasks executes a single task from the queue
|
||
func (lp *Loadpoint) processTasks() {
|
||
// test guard
|
||
if lp.tasks != nil {
|
||
if task, ok := lp.tasks.Dequeue(); ok {
|
||
task()
|
||
}
|
||
}
|
||
}
|
||
|
||
// startWakeUpTimer starts wakeUpTimer
|
||
func (lp *Loadpoint) startWakeUpTimer() {
|
||
if lp.vehicleHasFeature(api.WakeUpDisabled) {
|
||
return
|
||
}
|
||
|
||
lp.log.DEBUG.Printf("wake-up timer: start")
|
||
lp.wakeUpTimer.Start()
|
||
}
|
||
|
||
// stopWakeUpTimer stops wakeUpTimer
|
||
func (lp *Loadpoint) stopWakeUpTimer() {
|
||
if lp.wakeUpTimer.Running() {
|
||
lp.log.DEBUG.Printf("wake-up timer: stop")
|
||
lp.wakeUpTimer.Stop()
|
||
}
|
||
}
|
||
|
||
func (lp *Loadpoint) shouldBeConsistent() bool {
|
||
return lp.chargerUpdateCompleted() && lp.phaseSwitchCompleted()
|
||
}
|
||
|
||
// chargerUpdateCompleted returns true if enable command should be already processed by the charger (so we can try to sync charger and loadpoint)
|
||
func (lp *Loadpoint) chargerUpdateCompleted() bool {
|
||
return time.Since(lp.chargerSwitched) > chargerSwitchDuration
|
||
}
|
||
|
||
// phaseSwitchCompleted returns true if phase switch command should be already processed by the charger (so we can try to sync charger and loadpoint and are able to measure currents)
|
||
func (lp *Loadpoint) phaseSwitchCompleted() bool {
|
||
return time.Since(lp.phasesSwitched) > phaseSwitchDuration
|
||
}
|
||
|
||
// Update is the main control function. It reevaluates meters and charger state
|
||
func (lp *Loadpoint) Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64, dim *bool) {
|
||
// hold battery boost when SOC drops below the limit: stop draining the battery, but
|
||
// keep the vehicle prioritised over recharging it (via sitePower priorityAdjustment)
|
||
// until the vehicle disconnects. This holds the battery at the configured level
|
||
// instead of the naive on/off which lets the battery recharge and oscillate (#30558).
|
||
if boost := lp.GetBatteryBoost(); boost != boostDisabled && boost != boostHold {
|
||
if limit := lp.GetBatteryBoostLimit(); limit < 100 {
|
||
if batterySoc := lp.site.GetBatterySoc(); batterySoc < float64(limit) {
|
||
lp.log.DEBUG.Printf("battery boost hold: soc below limit (%.0f%% < %d%%)", batterySoc, limit)
|
||
lp.setBatteryBoost(boostHold)
|
||
}
|
||
}
|
||
}
|
||
|
||
// smart cost
|
||
smartCostActive, smartCostNextStart := lp.checkSmartLimit(lp.GetSmartCostLimit(), consumption, true)
|
||
lp.publish(keys.SmartCostActive, smartCostActive)
|
||
lp.publish(keys.SmartCostNextStart, smartCostNextStart)
|
||
|
||
smartFeedInPriorityActive, smartFeedInPriorityNextStart := lp.checkSmartLimit(lp.GetSmartFeedInPriorityLimit(), feedin, false)
|
||
lp.publish(keys.SmartFeedInPriorityActive, smartFeedInPriorityActive)
|
||
lp.publish(keys.SmartFeedInPriorityNextStart, smartFeedInPriorityNextStart)
|
||
|
||
// long-running tasks
|
||
lp.processTasks()
|
||
|
||
// read and publish meters first- charge power and currents have already been updated by the site
|
||
lp.updateChargeVoltages()
|
||
lp.phasesFromChargeCurrents()
|
||
|
||
lp.energyMetrics.SetEnvironment(greenShare, effPrice, effCo2)
|
||
|
||
// update ChargeRater here to make sure initial meter update is caught
|
||
lp.bus.Publish(evChargeCurrent, lp.offeredCurrent)
|
||
lp.bus.Publish(evChargePower, lp.chargePower)
|
||
|
||
// update progress and soc before status is updated
|
||
lp.publishChargeProgress()
|
||
lp.PublishEffectiveValues()
|
||
|
||
// §14a
|
||
if dimmer, ok := api.Cap[api.Dimmer](lp.charger); ok {
|
||
dimmed, err := dimmer.Dimmed()
|
||
if err != nil {
|
||
lp.log.ERROR.Printf("dimmed: %v", err)
|
||
return
|
||
}
|
||
|
||
if dim != nil {
|
||
if *dim != dimmed {
|
||
if err := dimmer.Dim(*dim); err != nil {
|
||
lp.log.ERROR.Printf("dim: %v", err)
|
||
return
|
||
}
|
||
|
||
lp.publish(keys.Dimmed, *dim)
|
||
lp.log.INFO.Printf("§14a dim: %t", *dim)
|
||
}
|
||
|
||
if *dim {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// read and publish status
|
||
welcomeCharge, err := lp.updateChargerStatus()
|
||
if err != nil {
|
||
lp.log.ERROR.Println(err)
|
||
return
|
||
}
|
||
|
||
lp.publish(keys.VehicleWelcomeActive, welcomeCharge)
|
||
lp.publish(keys.Connected, lp.connected())
|
||
lp.publish(keys.Charging, lp.charging())
|
||
|
||
lp.resetHeatingSession()
|
||
|
||
if sr, ok := api.Cap[api.StatusReasoner](lp.charger); ok && lp.GetStatus() == api.StatusB {
|
||
if r, err := sr.StatusReason(); err == nil {
|
||
lp.publish(keys.ChargerStatusReason, r)
|
||
} else {
|
||
lp.log.ERROR.Printf("charger status reason: %v", err)
|
||
}
|
||
}
|
||
|
||
// identify connected vehicle
|
||
if lp.connected() && !lp.chargerHasFeature(api.IntegratedDevice) {
|
||
// read identity and run associated action
|
||
lp.identifyVehicle()
|
||
|
||
// find vehicle by status for a couple of minutes after connecting
|
||
if lp.vehicleUnidentified() {
|
||
lp.identifyVehicleByStatus()
|
||
}
|
||
}
|
||
|
||
// release deferred connect notification once detection has settled
|
||
lp.maybePushVehicleConnect()
|
||
|
||
// publish soc after updating charger status to make sure
|
||
// initial update of connected state matches charger status
|
||
lp.publishSocAndRange()
|
||
|
||
// sync settings with charger
|
||
if err := lp.syncCharger(); err != nil {
|
||
lp.log.ERROR.Println(err)
|
||
return
|
||
}
|
||
|
||
mode := lp.GetMode()
|
||
lp.publish(keys.Mode, mode)
|
||
|
||
// update and publish plan without being short-circuited by modes etc.
|
||
plannerActive := lp.plannerActive()
|
||
|
||
// update and publish min soc not reached state
|
||
minSocNotReached := lp.minSocNotReached()
|
||
lp.publish(keys.MinSocNotReached, minSocNotReached)
|
||
|
||
// execute loading strategy
|
||
switch {
|
||
case !lp.connected():
|
||
// always disable charger if not connected
|
||
// https://github.com/evcc-io/evcc/issues/105
|
||
err = lp.setLimit(0)
|
||
|
||
case lp.scalePhasesRequired():
|
||
if err = lp.scalePhases(lp.phasesConfigured); errors.Is(err, api.ErrNotAvailable) {
|
||
// the charger cannot switch phases right now (e.g. EEBus charger
|
||
// with an ISO 15118 vehicle). Adopt the configured phase count so
|
||
// the switch is not re-attempted on every cycle (issue #29974).
|
||
lp.SetPhases(lp.phasesConfigured)
|
||
err = nil
|
||
}
|
||
|
||
case mode == api.ModeOff:
|
||
var current float64
|
||
if welcomeCharge {
|
||
current = lp.effectiveMinCurrent()
|
||
}
|
||
err = lp.setLimit(current)
|
||
|
||
// minimum or target charging
|
||
case minSocNotReached || plannerActive:
|
||
err = lp.fastCharging()
|
||
lp.resetPhaseTimer()
|
||
lp.elapsePVTimer() // let PV mode disable immediately afterwards
|
||
|
||
case lp.LimitEnergyReached():
|
||
lp.log.DEBUG.Printf("limitEnergy reached: %.0fkWh > %0.1fkWh", lp.GetChargedEnergy()/1e3, lp.limitEnergy)
|
||
err = lp.disableUnlessClimater()
|
||
|
||
case lp.LimitSocReached():
|
||
lp.log.DEBUG.Printf("limitSoc reached: %.1f%% > %d%%", lp.vehicleSoc, lp.EffectiveLimitSoc())
|
||
err = lp.disableUnlessClimater()
|
||
|
||
// immediate charging- must be placed after limits are evaluated
|
||
case mode == api.ModeNow:
|
||
err = lp.fastCharging()
|
||
|
||
case mode == api.ModeMinPV || mode == api.ModePV:
|
||
// cheap tariff
|
||
if smartCostActive {
|
||
rate, _ := consumption.At(time.Now())
|
||
lp.log.DEBUG.Printf("smart consumption active: %.2f", rate.Value)
|
||
err = lp.fastCharging()
|
||
lp.resetPhaseTimer()
|
||
lp.elapsePVTimer() // let PV mode disable immediately afterwards
|
||
break
|
||
}
|
||
|
||
// attractive feedin
|
||
if smartFeedInPriorityActive {
|
||
rate, _ := feedin.At(time.Now())
|
||
lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value)
|
||
|
||
if mode == api.ModeMinPV {
|
||
// minimize self-consumption to maximize feed-in by scaling down
|
||
// to the absolute minimum of 1 phase at min current
|
||
err = lp.minCharging()
|
||
} else {
|
||
err = lp.setLimit(0)
|
||
}
|
||
|
||
lp.resetPhaseTimer()
|
||
lp.elapsePVTimer() // let PV mode disable immediately afterwards
|
||
break
|
||
}
|
||
|
||
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryPower, batteryBuffered, batteryStart)
|
||
|
||
if targetCurrent == 0 && lp.vehicleClimateActive() {
|
||
targetCurrent = lp.effectiveMinCurrent()
|
||
}
|
||
|
||
if targetCurrent == 0 && welcomeCharge {
|
||
targetCurrent = lp.effectiveMinCurrent()
|
||
lp.resetPVTimer()
|
||
}
|
||
|
||
err = lp.setLimit(targetCurrent)
|
||
}
|
||
|
||
// Wake-up checks
|
||
if lp.enabled && lp.status == api.StatusB &&
|
||
// TODO take vehicle api limits into account
|
||
!lp.chargerHasFeature(api.IntegratedDevice) && int(lp.vehicleSoc) < lp.EffectiveLimitSoc() {
|
||
switch lp.wakeUpTimer.Elapsed() {
|
||
case WakeUpTimerElapsed:
|
||
lp.wakeUpVehicle()
|
||
case WakeUpTimerFinished:
|
||
lp.pushEvent(evVehicleAsleep)
|
||
}
|
||
}
|
||
|
||
// effective disabled status
|
||
// TODO use for §14a
|
||
// if remoteDisabled != loadpoint.RemoteEnable {
|
||
// lp.publish(keys.RemoteDisabled, remoteDisabled)
|
||
// }
|
||
|
||
// log any error
|
||
if err != nil {
|
||
lp.log.ERROR.Println(err)
|
||
}
|
||
}
|