package core import ( "errors" "fmt" "math" "regexp" "strings" "sync" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/soc" "github.com/evcc-io/evcc/core/wrapper" "github.com/evcc-io/evcc/provider" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/util" evbus "github.com/asaskevich/EventBus" "github.com/avast/retry-go/v3" "github.com/benbjohnson/clock" ) 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 minActiveCurrent = 1.0 // minimum current at which a phase is treated as active vehicleDetectInterval = 3 * time.Minute vehicleDetectDuration = 10 * time.Minute settleDuration = 100 * time.Millisecond ) // PollConfig defines the vehicle polling mode and interval type PollConfig struct { Mode string `mapstructure:"mode"` // polling mode charging (default), connected, always Interval time.Duration `mapstructure:"interval"` // interval when not charging } // SoCConfig defines soc settings, estimation and update behaviour type SoCConfig struct { Poll PollConfig `mapstructure:"poll"` Estimate bool `mapstructure:"estimate"` Min int `mapstructure:"min"` // Default minimum SoC, guarded by mutex Target int `mapstructure:"target"` // Default target SoC, guarded by mutex } // Poll modes const ( pollCharging = "charging" pollConnected = "connected" pollAlways = "always" pollInterval = 60 * time.Minute ) // ThresholdConfig defines enable/disable hysteresis parameters type ThresholdConfig struct { Delay time.Duration Threshold float64 } // ActionConfig defines an action to take on event type ActionConfig struct { Mode api.ChargeMode `mapstructure:"mode"` // Charge mode to apply when car disconnected TargetSoC int `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected } // 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 pushChan chan<- push.Event // notifications uiChan chan<- util.Param // client push messages lpChan chan<- *LoadPoint // update requests log *util.Logger // exposed public configuration sync.Mutex // guard status Mode api.ChargeMode `mapstructure:"mode"` // Charge mode, guarded by mutex Title string `mapstructure:"title"` // UI title Phases int `mapstructure:"phases"` // Charger enabled phases ChargerRef string `mapstructure:"charger"` // Charger reference VehicleRef string `mapstructure:"vehicle"` // Vehicle reference VehiclesRef []string `mapstructure:"vehicles"` // Vehicles reference Meters struct { ChargeMeterRef string `mapstructure:"charge"` // Charge meter reference } SoC SoCConfig OnDisconnect ActionConfig `mapstructure:"onDisconnect"` OnIdentify map[string]ActionConfig `mapstructure:"onIdentify"` Enable, Disable ThresholdConfig MinCurrent float64 // PV mode: start current Min+PV mode: min current MaxCurrent float64 // Max allowed current. Physically ensured by the charger GuardDuration time.Duration // charger enable/disable minimum holding time enabled bool // Charger enabled state activePhases int // Charger active phases as used by vehicle chargeCurrent float64 // Charger current limit guardUpdated time.Time // Charger enabled/disabled timestamp socUpdated time.Time // SoC updated timestamp (poll: connected) vehicleConnected time.Time // Vehicle connected timestamp vehicleConnectedTicker *clock.Ticker vehicleID string charger api.Charger chargeTimer api.ChargeTimer chargeRater api.ChargeRater chargeMeter api.Meter // Charger usage meter vehicle api.Vehicle // Currently active vehicle vehicles []api.Vehicle // Assigned vehicles socEstimator *soc.Estimator socTimer *soc.Timer // cached state status api.ChargeStatus // Charger status remoteDemand loadpoint.RemoteDemand // External status demand chargePower float64 // Charging power chargeCurrents []float64 // Phase currents connectedTime time.Time // Time when vehicle was connected pvTimer time.Time // PV enabled/disable timer phaseTimer time.Time // 1p3p switch timer // charge progress vehicleSoc float64 // Vehicle SoC chargeDuration time.Duration // Charge duration chargedEnergy float64 // Charged energy while connected in Wh chargeRemainingDuration time.Duration // Remaining charge duration chargeRemainingEnergy float64 // Remaining charge energy in Wh } // NewLoadPointFromConfig creates a new loadpoint func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[string]interface{}) (*LoadPoint, error) { lp := NewLoadPoint(log) if err := util.DecodeOther(other, &lp); err != nil { return nil, err } // set vehicle polling mode switch lp.SoC.Poll.Mode = strings.ToLower(lp.SoC.Poll.Mode); lp.SoC.Poll.Mode { case pollCharging: case pollConnected, 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: if lp.SoC.Poll.Mode != "" { lp.log.WARN.Printf("invalid poll mode: %s", lp.SoC.Poll.Mode) } lp.SoC.Poll.Mode = pollConnected } // set vehicle polling interval if lp.SoC.Poll.Interval < pollInterval { if lp.SoC.Poll.Interval == 0 { lp.SoC.Poll.Interval = pollInterval } else { lp.log.WARN.Printf("poll interval '%v' is lower than %v and may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.SoC.Poll.Interval, pollInterval) } } if lp.SoC.Target == 0 { lp.SoC.Target = lp.OnDisconnect.TargetSoC // use disconnect value as default soc if lp.SoC.Target == 0 { lp.SoC.Target = 100 } } if lp.MinCurrent == 0 { lp.log.WARN.Println("minCurrent must not be zero") } if lp.MaxCurrent <= lp.MinCurrent { lp.log.WARN.Println("maxCurrent must be larger than minCurrent") } if lp.Meters.ChargeMeterRef != "" { lp.chargeMeter = cp.Meter(lp.Meters.ChargeMeterRef) } // multiple vehicles for _, ref := range lp.VehiclesRef { vehicle := cp.Vehicle(ref) lp.vehicles = append(lp.vehicles, vehicle) } // single vehicle if lp.VehicleRef != "" { if len(lp.vehicles) > 0 { return nil, errors.New("cannot have vehicle and vehicles both") } vehicle := cp.Vehicle(lp.VehicleRef) lp.vehicles = append(lp.vehicles, vehicle) } if lp.ChargerRef == "" { return nil, errors.New("missing charger") } lp.charger = cp.Charger(lp.ChargerRef) lp.configureChargerType(lp.charger) // ensure 1p setup for switchable charger (https://github.com/evcc-io/evcc/issues/1572) if _, ok := lp.charger.(api.ChargePhases); ok { lp.setPhases(1) } // allow target charge handler to access loadpoint lp.socTimer = soc.NewTimer(lp.log, &adapter{LoadPoint: lp}) 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) } return lp, nil } // NewLoadPoint creates a LoadPoint with sane defaults func NewLoadPoint(log *util.Logger) *LoadPoint { clock := clock.New() bus := evbus.New() lp := &LoadPoint{ log: log, // logger clock: clock, // mockable time bus: bus, // event bus Mode: api.ModeOff, Phases: 3, status: api.StatusNone, MinCurrent: 6, // A MaxCurrent: 16, // A GuardDuration: 5 * time.Minute, } return lp } // 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) { // ensure charge meter exists if lp.chargeMeter == nil { if mt, ok := charger.(api.Meter); ok { lp.chargeMeter = mt } else { mt := &wrapper.ChargeMeter{} _ = lp.bus.Subscribe(evChargeCurrent, lp.evChargeCurrentWrappedMeterHandler) _ = lp.bus.Subscribe(evChargeStop, func() { mt.SetPower(0) }) lp.chargeMeter = mt } } // ensure charge rater exists if rt, ok := charger.(api.ChargeRater); ok { lp.chargeRater = rt } 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 := charger.(api.ChargeTimer); 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 } } // pushEvent sends push messages to clients func (lp *LoadPoint) pushEvent(event string) { lp.pushChan <- push.Event{Event: event} } // publish sends values to UI and databases func (lp *LoadPoint) publish(key string, val interface{}) { if lp.uiChan != nil { 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) // soc update reset lp.socUpdated = time.Time{} } // evChargeStopHandler sends external stop event func (lp *LoadPoint) evChargeStopHandler() { lp.log.INFO.Println("stop charging <-") lp.pushEvent(evChargeStop) // soc update reset lp.socUpdated = time.Time{} } // evVehicleConnectHandler sends external start event func (lp *LoadPoint) evVehicleConnectHandler() { lp.log.INFO.Printf("car connected") // energy lp.chargedEnergy = 0 lp.publish("chargedEnergy", lp.chargedEnergy) // duration lp.connectedTime = lp.clock.Now() lp.publish("connectedDuration", time.Duration(0)) // soc update reset lp.socUpdated = time.Time{} // soc update reset on car change if lp.socEstimator != nil { lp.socEstimator.Reset() } // flush all vehicles before updating state lp.log.DEBUG.Println("vehicle api refresh") provider.ResetCached() // identify active vehicle lp.startVehicleDetection() // immediately allow pv mode activity lp.elapsePVTimer() lp.pushEvent(evVehicleConnect) } // evVehicleDisconnectHandler sends external start event func (lp *LoadPoint) evVehicleDisconnectHandler() { lp.log.INFO.Println("car disconnected") // energy and duration lp.publish("chargedEnergy", lp.chargedEnergy) lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime)) lp.pushEvent(evVehicleDisconnect) // remove active vehicle if len(lp.vehicles) > 1 { lp.setActiveVehicle(nil) } // set default mode on disconnect lp.applyAction(lp.OnDisconnect) // soc update reset lp.socUpdated = time.Time{} } // evChargeCurrentHandler publishes the charge current func (lp *LoadPoint) evChargeCurrentHandler(current float64) { if !lp.enabled { current = 0 } lp.publish("chargeCurrent", 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 !lp.enabled || lp.GetStatus() != api.StatusC { // if disabled we cannot be charging power = 0 } // handler only called if charge meter was replaced by dummy lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power) } // applyAction executes the action func (lp *LoadPoint) applyAction(action ActionConfig) { if action.Mode != "" && lp.GetMode() != api.ModeEmpty { lp.SetMode(action.Mode) } if action.TargetSoC != 0 { _ = lp.SetTargetSoC(action.TargetSoC) } } // Name returns the human-readable loadpoint title func (lp *LoadPoint) Name() string { return lp.Title } // Prepare loadpoint configuration by adding missing helper elements func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event, lpChan chan<- *LoadPoint) { lp.uiChan = uiChan lp.pushChan = pushChan lp.lpChan = lpChan // assume all phases are active lp.activePhases = lp.Phases // 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) // publish initial values lp.publish("title", lp.Title) lp.publish("minCurrent", lp.MinCurrent) lp.publish("maxCurrent", lp.MaxCurrent) lp.publish("phases", lp.Phases) lp.publish("activePhases", lp.activePhases) lp.publish("hasVehicle", len(lp.vehicles) > 0) lp.Lock() lp.publish("mode", lp.Mode) lp.publish("targetSoC", lp.SoC.Target) lp.publish("minSoC", lp.SoC.Min) lp.Unlock() // always treat single vehicle as attached to allow poll mode: always if len(lp.vehicles) == 1 { lp.setActiveVehicle(lp.vehicles[0]) } // start detection if we have multiple vehicles if len(lp.vehicles) > 1 { lp.startVehicleDetection() } // read initial charger state to prevent immediately disabling charger if enabled, err := lp.charger.Enabled(); err == nil { if lp.enabled = enabled; enabled { lp.guardUpdated = lp.clock.Now() // set defined current for use by pv mode _ = lp.setLimit(lp.GetMinCurrent(), false) } } else { lp.log.ERROR.Printf("charger: %v", err) } // allow charger to access loadpoint if ctrl, ok := lp.charger.(loadpoint.Controller); ok { ctrl.LoadpointControl(lp) } } // syncCharger updates charger status and synchronizes it with expectations func (lp *LoadPoint) syncCharger() { enabled, err := lp.charger.Enabled() if err == nil { if enabled != lp.enabled { lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled]) err = lp.charger.Enable(lp.enabled) } if !enabled && lp.GetStatus() == api.StatusC { lp.log.WARN.Println("charger logic error: disabled but charging") } } if err != nil { lp.log.ERROR.Printf("charger: %v", err) } } // setLimit applies charger current limits and enables/disables accordingly func (lp *LoadPoint) setLimit(chargeCurrent float64, force bool) (err error) { // set current if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.GetMinCurrent() { if charger, ok := lp.charger.(api.ChargerEx); ok { err = charger.MaxCurrentMillis(chargeCurrent) } else { chargeCurrent = math.Trunc(chargeCurrent) err = lp.charger.MaxCurrent(int64(chargeCurrent)) } if err == nil { lp.log.DEBUG.Printf("max charge current: %.3gA", chargeCurrent) lp.chargeCurrent = chargeCurrent lp.bus.Publish(evChargeCurrent, chargeCurrent) } else { err = fmt.Errorf("max charge current %.3g: %w", chargeCurrent, err) } } // set enabled/disabled if enabled := chargeCurrent >= lp.GetMinCurrent(); enabled != lp.enabled && err == nil { if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force { lp.log.DEBUG.Printf("charger %s: contactor delay %v", status[enabled], remaining) return nil } // sleep vehicle if car, ok := lp.vehicle.(api.VehicleStopCharge); !enabled && ok { // log but don't propagate if err := car.StopCharge(); err != nil { lp.log.ERROR.Printf("vehicle remote charge stop: %v", err) } } if err = lp.charger.Enable(enabled); err == nil { lp.log.DEBUG.Printf("charger %s", status[enabled]) lp.enabled = enabled lp.guardUpdated = lp.clock.Now() lp.bus.Publish(evChargeCurrent, chargeCurrent) // wake up vehicle if car, ok := lp.vehicle.(api.VehicleStartCharge); enabled && ok { // log but don't propagate if err := car.StartCharge(); err != nil { lp.log.ERROR.Printf("vehicle remote charge start: %v", err) } } } else { err = fmt.Errorf("charger %s: %w", status[enabled], err) } } return err } // 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 } // charging returns the EVs charging state func (lp *LoadPoint) setStatus(status api.ChargeStatus) { lp.Lock() defer lp.Unlock() lp.status = status } // targetSocReached checks if target is configured and reached. // If vehicle is not configured this will always return false func (lp *LoadPoint) targetSocReached() bool { return lp.vehicle != nil && lp.SoC.Target > 0 && lp.SoC.Target < 100 && lp.vehicleSoc >= float64(lp.SoC.Target) } // minSocNotReached checks if minimum is configured and not reached. // If vehicle is not configured this will always return true func (lp *LoadPoint) minSocNotReached() bool { return lp.vehicle != nil && lp.SoC.Min > 0 && lp.vehicleSoc < float64(lp.SoC.Min) } // climateActive checks if vehicle has active climate request func (lp *LoadPoint) climateActive() bool { if cl, ok := lp.vehicle.(api.VehicleClimater); ok { active, outsideTemp, targetTemp, err := cl.Climater() if err == nil { lp.log.DEBUG.Printf("climater active: %v, target temp: %.1f°C, outside temp: %.1f°C", active, targetTemp, outsideTemp) status := "off" if active { status = "on" switch { case outsideTemp < targetTemp: status = "heating" case outsideTemp > targetTemp: status = "cooling" } } lp.publish("climater", status) return active } if !errors.Is(err, api.ErrNotAvailable) { lp.log.ERROR.Printf("climater: %v", err) } } return false } // remoteControlled returns true if remote control status is active func (lp *LoadPoint) remoteControlled(demand loadpoint.RemoteDemand) bool { lp.Lock() defer lp.Unlock() return lp.remoteDemand == demand } // identifyVehicle reads vehicle identification from charger func (lp *LoadPoint) identifyVehicle() { identifier, ok := lp.charger.(api.Identifier) if !ok { return } id, err := identifier.Identify() if err != nil { lp.log.ERROR.Println("charger vehicle id:", err) return } if lp.vehicleID == id { return } // vehicle found or removed lp.vehicleID = id lp.log.DEBUG.Println("charger vehicle id:", id) lp.publish("vehicleIdentity", id) if id != "" { if vehicle := lp.selectVehicleByID(id); vehicle != nil { lp.setActiveVehicle(vehicle) } if action, ok := lp.OnIdentify[id]; ok { lp.log.DEBUG.Println("running vehicle action:", action) lp.applyAction(action) } } } // selectVehicleByID selects the vehicle with the given ID func (lp *LoadPoint) selectVehicleByID(id string) api.Vehicle { // find exact match for _, vehicle := range lp.vehicles { if vid, err := vehicle.Identify(); err == nil && vid == id { return vehicle } } // find placeholder match for _, vehicle := range lp.vehicles { if vid, err := vehicle.Identify(); err == nil && vid != "" { re, err := regexp.Compile(strings.ReplaceAll(vid, "*", ".*?")) if err != nil { lp.log.ERROR.Printf("vehicle id: %v", err) continue } if re.MatchString(id) { return vehicle } } } return nil } // setActiveVehicle assigns currently active vehicle and configures soc estimator func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) { if lp.vehicle == vehicle { return } from := "unknown" if lp.vehicle != nil { coordinator.release(lp.vehicle) from = lp.vehicle.Title() } to := "unknown" if vehicle != nil { coordinator.aquire(lp, vehicle) to = vehicle.Title() } lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to) if lp.vehicle = vehicle; vehicle != nil { lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, lp.SoC.Estimate) lp.publish("vehiclePresent", true) lp.publish("vehicleTitle", lp.vehicle.Title()) lp.publish("vehicleCapacity", lp.vehicle.Capacity()) lp.publish("vehicleOdometer", 0.0) } else { lp.socEstimator = nil lp.publish("vehiclePresent", false) lp.publish("vehicleTitle", "") lp.publish("vehicleCapacity", int64(0)) lp.publish("vehicleOdometer", 0.0) } } // startVehicleDetection resets connection timer and starts api refresh timer func (lp *LoadPoint) startVehicleDetection() { lp.vehicleConnected = lp.clock.Now() lp.vehicleConnectedTicker = lp.clock.Ticker(vehicleDetectInterval) } // vehicleUnidentified checks if loadpoint has multiple vehicles associated and starts discovery period func (lp *LoadPoint) vehicleUnidentified() bool { res := len(lp.vehicles) > 1 && lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration // request vehicle api refresh while waiting to identify if res { select { case <-lp.vehicleConnectedTicker.C: lp.log.DEBUG.Println("vehicle api refresh") provider.ResetCached() default: } } return res } // identifyVehicleByStatus validates if the active vehicle is still connected to the loadpoint func (lp *LoadPoint) identifyVehicleByStatus() { if len(lp.vehicles) <= 1 { return } if vehicle := coordinator.identifyVehicleByStatus(lp.log, lp, lp.vehicles); vehicle != nil { lp.setActiveVehicle(vehicle) return } // remove previous vehicle if status was not confirmed if _, ok := lp.vehicle.(api.ChargeState); ok { lp.setActiveVehicle(nil) } } // updateChargerStatus updates charger status and detects car connected/disconnected events func (lp *LoadPoint) updateChargerStatus() error { status, err := lp.charger.Status() if err != nil { return err } lp.log.DEBUG.Printf("charger status: %s", status) if prevStatus := lp.GetStatus(); status != prevStatus { lp.setStatus(status) // changed from empty (initial startup) - set connected without sending message if prevStatus == api.StatusNone { lp.connectedTime = lp.clock.Now() lp.publish("connectedDuration", time.Duration(0)) } // changed from A - connected if prevStatus == api.StatusA { lp.bus.Publish(evVehicleConnect) } // changed to C - start/stop charging cycle - handle before disconnect to update energy if lp.charging() { lp.bus.Publish(evChargeStart) } else if prevStatus == api.StatusC { lp.bus.Publish(evChargeStop) } // changed to A - disconnected if status == api.StatusA { lp.bus.Publish(evVehicleDisconnect) } // update whenever there is a state change lp.bus.Publish(evChargeCurrent, lp.chargeCurrent) } return nil } // effectiveCurrent returns the currently effective charging current func (lp *LoadPoint) effectiveCurrent() float64 { if lp.GetStatus() != api.StatusC { return 0 } // adjust actual current for vehicles like Zoe where it remains below target if lp.chargeCurrents != nil { cur := lp.chargeCurrents[0] return math.Min(cur+2.0, lp.chargeCurrent) } return lp.chargeCurrent } // elapsePVTimer puts the pv enable/disable timer into elapsed state func (lp *LoadPoint) elapsePVTimer() { lp.pvTimer = lp.clock.Now().Add(-lp.Disable.Delay) lp.guardUpdated = lp.clock.Now().Add(-lp.GuardDuration) } // scalePhasesIfAvailable scales if api.ChargePhases is available func (lp *LoadPoint) scalePhasesIfAvailable(phases int) error { err := lp.scalePhases(phases) if errors.Is(err, api.ErrNotAvailable) { return nil } return err } // setPhases sets the number of enabled phases without modifying the charger func (lp *LoadPoint) setPhases(phases int) { lp.Lock() defer lp.Unlock() if lp.Phases != phases { lp.Phases = phases lp.publish("phases", lp.Phases) } } // scalePhases adjusts the number of active phases and returns the appropriate charging current. // Returns api.ErrNotAvailable if api.ChargePhases is not available. func (lp *LoadPoint) scalePhases(phases int) error { if phases != 1 && phases != 3 { return fmt.Errorf("invalid number of phases: %d", phases) } cp, ok := lp.charger.(api.ChargePhases) if !ok { return api.ErrNotAvailable } if lp.GetPhases() != phases { // disable charger - this will also stop the car charging using the api if available if err := lp.setLimit(0, true); err != nil { return err } // switch phases if err := cp.Phases1p3p(phases); err != nil { return fmt.Errorf("switch phases: %w", err) } // update setting lp.setPhases(phases) // disable phase timer lp.phaseTimer = time.Time{} // allow pv mode to re-enable charger right away lp.elapsePVTimer() } return nil } // pvScalePhases switches phases if necessary and returns if switch occurred func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float64) bool { // correct charger state inconsistency (https://github.com/evcc-io/evcc/issues/1572) phases := lp.GetPhases() if phases < lp.activePhases { phases = 3 lp.setPhases(3) } var waiting bool targetCurrent := availablePower / Voltage / float64(lp.activePhases) // scale down phases if targetCurrent < minCurrent && phases > 1 && lp.activePhases > 1 { lp.log.DEBUG.Printf("available power below %dp min threshold of %.0fW", lp.activePhases, float64(lp.activePhases)*Voltage*minCurrent) if lp.phaseTimer.IsZero() { lp.log.DEBUG.Printf("start phase disable timer: %v", lp.Disable.Delay) lp.phaseTimer = lp.clock.Now() } elapsed := lp.clock.Since(lp.phaseTimer) if elapsed >= lp.Disable.Delay { lp.log.DEBUG.Println("phase disable timer elapsed") if err := lp.scalePhases(1); err == nil { lp.log.DEBUG.Printf("switched phases: 1p @ %.0fW", availablePower) // if charging is disabled, current detection will not switch active phases to 1p // make sure we can start charging by assuming 1p during next cycle lp.activePhases = 1 return true } else { lp.log.ERROR.Printf("switch phases: %v", err) } } waiting = true lp.log.DEBUG.Printf("phase disable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second)) } // scale up phases if min3pCurrent := powerToCurrent(availablePower, 3); min3pCurrent >= minCurrent && phases == 1 { lp.log.DEBUG.Printf("available power above 3p min threshold of %.0fW", 3*Voltage*minCurrent) if lp.phaseTimer.IsZero() { lp.log.DEBUG.Printf("start phase enable timer: %v", lp.Enable.Delay) lp.phaseTimer = lp.clock.Now() } elapsed := lp.clock.Since(lp.phaseTimer) if elapsed >= lp.Disable.Delay { lp.log.DEBUG.Println("phase enable timer elapsed") if err := lp.scalePhases(3); err == nil { lp.log.DEBUG.Printf("switched phases: 3p @ %.0fW", availablePower) return true } else { lp.log.ERROR.Printf("switch phases: %v", err) } } waiting = true lp.log.DEBUG.Printf("phase enable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second)) } // reset timer to disabled state if !waiting && !lp.phaseTimer.IsZero() { lp.log.DEBUG.Printf("phase timer reset") lp.phaseTimer = time.Time{} } return false } // pvMaxCurrent calculates the maximum target current for PV mode func (lp *LoadPoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered bool) float64 { // read only once to simplify testing minCurrent := lp.GetMinCurrent() maxCurrent := lp.GetMaxCurrent() // calculate target charge current from delta power and actual current effectiveCurrent := lp.effectiveCurrent() deltaCurrent := powerToCurrent(-sitePower, lp.activePhases) targetCurrent := math.Max(effectiveCurrent+deltaCurrent, 0) lp.log.DEBUG.Printf("max charge current: %.3gA = %.3gA + %.3gA (%.0fW @ %dp)", targetCurrent, effectiveCurrent, deltaCurrent, sitePower, lp.activePhases) // switch phases up/down if _, ok := lp.charger.(api.ChargePhases); ok { availablePower := -sitePower + lp.chargePower // in case of scaling, keep charger disabled for this cycle if lp.pvScalePhases(availablePower, minCurrent, maxCurrent) { return 0 } } // in MinPV mode return at least minCurrent if (mode == api.ModeMinPV || batteryBuffered) && targetCurrent < minCurrent { return minCurrent } if mode == api.ModePV && lp.enabled && targetCurrent < minCurrent { // kick off disable sequence if sitePower >= lp.Disable.Threshold { lp.log.DEBUG.Printf("site power %.0fW >= disable threshold %.0fW", sitePower, lp.Disable.Threshold) if lp.pvTimer.IsZero() { lp.log.DEBUG.Printf("start pv disable timer: %v", lp.Disable.Delay) lp.pvTimer = lp.clock.Now() } elapsed := lp.clock.Since(lp.pvTimer) if elapsed >= lp.Disable.Delay { lp.log.DEBUG.Println("pv disable timer elapsed") return 0 } lp.log.DEBUG.Printf("pv disable timer remaining: %v", (lp.Disable.Delay - elapsed).Round(time.Second)) } else { // reset timer lp.log.DEBUG.Printf("reset pv disable timer: %v", lp.Disable.Delay) lp.pvTimer = lp.clock.Now() } lp.log.DEBUG.Println("pv enable 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 < enable threshold %.0fW", sitePower, lp.Enable.Threshold) if lp.pvTimer.IsZero() { lp.log.DEBUG.Printf("start pv enable timer: %v", lp.Enable.Delay) lp.pvTimer = lp.clock.Now() } elapsed := lp.clock.Since(lp.pvTimer) if elapsed >= lp.Enable.Delay { lp.log.DEBUG.Println("pv enable timer elapsed") return minCurrent } lp.log.DEBUG.Printf("pv enable timer remaining: %v", (lp.Enable.Delay - elapsed).Round(time.Second)) } else { // reset timer lp.log.DEBUG.Printf("reset pv enable timer: %v", lp.Enable.Delay) lp.pvTimer = lp.clock.Now() } lp.log.DEBUG.Println("pv enable timer: keep disabled") return 0 } // reset timer to disabled state if !lp.pvTimer.IsZero() { lp.log.DEBUG.Printf("pv timer reset") lp.pvTimer = time.Time{} } // cap at maximum current targetCurrent = math.Min(targetCurrent, maxCurrent) return targetCurrent } // updateChargePower updates charge meter power func (lp *LoadPoint) updateChargePower() { err := retry.Do(func() error { value, err := lp.chargeMeter.CurrentPower() if err != nil { return err } lp.chargePower = value // update value if no error lp.log.DEBUG.Printf("charge power: %.0fW", value) lp.publish("chargePower", value) if lp.chargePower < 0 { lp.log.WARN.Printf("charge power must not be negative: %.0f", lp.chargePower) } return nil }, retryOptions...) if err != nil { lp.log.ERROR.Printf("charge meter: %v", err) } } // updateChargeCurrents uses MeterCurrent interface to count phases with current >=1A func (lp *LoadPoint) updateChargeCurrents() { lp.chargeCurrents = nil phaseMeter, ok := lp.chargeMeter.(api.MeterCurrent) if !ok { // guess active phases from power consumption // assumes that chargePower has been updated before if lp.charging() && lp.chargeCurrent > 0 { phases := int(math.Round(lp.chargePower / Voltage / lp.chargeCurrent)) if phases >= 1 && phases <= 3 { lp.activePhases = phases lp.log.DEBUG.Printf("detected phases: %dp (%.1fA @ %.0fW)", lp.activePhases, lp.chargeCurrent, lp.chargePower) lp.publish("activePhases", lp.activePhases) } } return } i1, i2, i3, err := phaseMeter.Currents() if err != nil { lp.log.ERROR.Printf("charge meter: %v", err) return } lp.chargeCurrents = []float64{i1, i2, i3} lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents) lp.publish("chargeCurrents", lp.chargeCurrents) if lp.charging() { var phases int for _, i := range lp.chargeCurrents { if i >= minActiveCurrent { phases++ } } if phases >= 1 { lp.activePhases = phases lp.log.DEBUG.Printf("detected phases: %dp %.3gA", lp.activePhases, lp.chargeCurrents) lp.publish("activePhases", lp.activePhases) } } } // publish charged energy and duration func (lp *LoadPoint) publishChargeProgress() { if f, err := lp.chargeRater.ChargedEnergy(); err == nil { lp.chargedEnergy = 1e3 * f // convert to Wh } else { lp.log.ERROR.Printf("charge rater: %v", err) } if d, err := lp.chargeTimer.ChargingTime(); err == nil { lp.chargeDuration = d.Round(time.Second) } else { lp.log.ERROR.Printf("charge timer: %v", err) } lp.publish("chargedEnergy", lp.chargedEnergy) lp.publish("chargeDuration", lp.chargeDuration) } // socPollAllowed validates charging state against polling mode func (lp *LoadPoint) socPollAllowed() bool { remaining := lp.SoC.Poll.Interval - lp.clock.Since(lp.socUpdated) honourUpdateInterval := lp.SoC.Poll.Mode == pollAlways || lp.SoC.Poll.Mode == pollConnected && lp.connected() if honourUpdateInterval && remaining > 0 { lp.log.DEBUG.Printf("next soc poll remaining time: %v", remaining.Truncate(time.Second)) } return lp.charging() || honourUpdateInterval && (remaining <= 0) || lp.connected() && lp.socUpdated.IsZero() } // checks if the connected charger can provide SoC to the connected vehicle func (lp *LoadPoint) socProvidedByCharger() bool { if charger, ok := lp.charger.(api.Battery); ok { if _, err := charger.SoC(); err == nil { return true } } return false } // publish state of charge, remaining charge duration and range func (lp *LoadPoint) publishSoCAndRange() { if lp.socEstimator == nil { return } if lp.socPollAllowed() || lp.socProvidedByCharger() { lp.socUpdated = lp.clock.Now() f, err := lp.socEstimator.SoC(lp.chargedEnergy) if err == nil { lp.vehicleSoc = math.Trunc(f) lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.vehicleSoc) lp.publish("vehicleSoC", lp.vehicleSoc) if lp.charging() { lp.setRemainingDuration(lp.socEstimator.RemainingChargeDuration(lp.chargePower, lp.SoC.Target)) } else { lp.setRemainingDuration(-1) } lp.setRemainingEnergy(1e3 * lp.socEstimator.RemainingChargeEnergy(lp.SoC.Target)) // range if vs, ok := lp.vehicle.(api.VehicleRange); ok { if rng, err := vs.Range(); err == nil { lp.log.DEBUG.Printf("vehicle range: %vkm", rng) lp.publish("vehicleRange", rng) } } // odometer if vs, ok := lp.vehicle.(api.VehicleOdometer); ok { if odo, err := vs.Odometer(); err == nil { lp.log.DEBUG.Printf("vehicle odometer: %vkm", odo) lp.publish("vehicleOdometer", odo) } } } else { if errors.Is(err, api.ErrMustRetry) { lp.socUpdated = time.Time{} } else { lp.log.ERROR.Printf("vehicle soc: %v", err) } } return } // reset if poll: connected/charging and not connected if lp.SoC.Poll.Mode != pollAlways && !lp.connected() { lp.publish("vehicleSoC", -1) lp.publish("chargeRemainingDuration", time.Duration(-1)) // range lp.publish("vehicleRange", -1) } } // Update is the main control function. It reevaluates meters and charger state func (lp *LoadPoint) Update(sitePower float64, cheap bool, batteryBuffered bool) { mode := lp.GetMode() lp.publish("mode", mode) // read and publish meters first lp.updateChargePower() lp.updateChargeCurrents() // update ChargeRater here to make sure initial meter update is caught lp.bus.Publish(evChargeCurrent, lp.chargeCurrent) lp.bus.Publish(evChargePower, lp.chargePower) // update progress and soc before status is updated lp.publishChargeProgress() // read and publish status if err := lp.updateChargerStatus(); err != nil { lp.log.ERROR.Printf("charger: %v", err) return } lp.publish("connected", lp.connected()) lp.publish("charging", lp.charging()) lp.publish("enabled", lp.enabled) // identify connected vehicle if lp.connected() { // read identity and run associated action lp.identifyVehicle() // find vehicle by status for a couple of minutes after connecting if lp.vehicleUnidentified() { lp.identifyVehicleByStatus() } } // publish soc after updating charger status to make sure // initial update of connected state matches charger status lp.publishSoCAndRange() // sync settings with charger lp.syncCharger() // check if car connected and ready for charging var err error // track if remote disabled is actually active remoteDisabled := loadpoint.RemoteEnable // 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, false) case lp.targetSocReached(): lp.log.DEBUG.Printf("targetSoC reached: %.1f > %d", lp.vehicleSoc, lp.SoC.Target) var targetCurrent float64 // zero disables if lp.climateActive() { lp.log.DEBUG.Println("climater active") targetCurrent = lp.GetMinCurrent() } err = lp.setLimit(targetCurrent, true) lp.socTimer.Reset() // once SoC is reached, the target charge request is removed // OCPP has priority over target charging case lp.remoteControlled(loadpoint.RemoteHardDisable): remoteDisabled = loadpoint.RemoteHardDisable fallthrough case mode == api.ModeOff: err = lp.setLimit(0, true) case lp.minSocNotReached(): // 3p if available if err = lp.scalePhasesIfAvailable(3); err == nil { err = lp.setLimit(lp.GetMaxCurrent(), true) } lp.elapsePVTimer() // let PV mode disable immediately afterwards case mode == api.ModeNow: // 3p if available if err = lp.scalePhasesIfAvailable(3); err == nil { err = lp.setLimit(lp.GetMaxCurrent(), true) } // target charging case lp.socTimer.DemandActive() && false: targetCurrent := lp.socTimer.Handle() err = lp.setLimit(targetCurrent, true) case mode == api.ModeMinPV || mode == api.ModePV: targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered) lp.log.DEBUG.Printf("pv max charge current: %.3gA", targetCurrent) var required bool // false if targetCurrent == 0 && lp.climateActive() { targetCurrent = lp.GetMaxCurrent() required = true } // tariff if cheap { targetCurrent = lp.GetMaxCurrent() lp.log.DEBUG.Printf("cheap tariff: %.3gA", targetCurrent) required = true } // Sunny Home Manager if lp.remoteControlled(loadpoint.RemoteSoftDisable) { remoteDisabled = loadpoint.RemoteSoftDisable targetCurrent = 0 required = true } err = lp.setLimit(targetCurrent, required) } // effective disabled status if remoteDisabled != loadpoint.RemoteEnable { lp.publish("remoteDisabled", remoteDisabled) } // read and publish meters after settings are applied if err == nil { time.Sleep(settleDuration) lp.updateChargePower() lp.updateChargeCurrents() } else { lp.log.ERROR.Println(err) } }