evcc-io/vehicle/fiat/controller.go

228 lines
10 KiB
Go

package fiat
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
type Controller struct {
pvd *Provider
api *API
log *util.Logger
vin string
pin string
}
// NewController creates a vehicle current and charge controller
func NewController(provider *Provider, api *API, log *util.Logger, vin string, pin string) *Controller {
impl := &Controller{
pvd: provider,
api: api,
log: log,
vin: vin,
pin: pin,
}
return impl
}
var _ api.CurrentController = (*Controller)(nil)
// MaxCurrent implements the api.CurrentController interface
func (c *Controller) MaxCurrent(current int64) error {
// Even if we cannot control the current, this interface must be implemented otherwise the ChargeEnable is never called
return nil
}
var _ api.ChargeController = (*Controller)(nil)
// ChargeEnable implements the api.ChargeController interface
func (c *Controller) ChargeEnable(enable bool) error {
if c.pin == "" {
return api.ErrMissingCredentials
}
// get current schedule status from provider (cached)
stat, err := c.pvd.statusG()
if err != nil {
return err
}
if stat.EvInfo == nil || stat.EvInfo.Schedules == nil || len(stat.EvInfo.Schedules) == 0 {
return api.ErrNotAvailable
}
hasChanged := false // Will track if we made any change to the schedule to avoid unnecessary updates through API call
now := time.Now()
if enable {
// Start charging from now until end of day (23:55)
hasChanged = hasChanged || c.configureChargeSchedule(&stat.EvInfo.Schedules[0], now, time.Time{})
} else {
// Stop charging: set charge end time to now to stop charging as soon as possible; use empty time to keep start time as it was for history in Fiat app
hasChanged = hasChanged || c.configureChargeSchedule(&stat.EvInfo.Schedules[0], time.Time{}, now)
}
// make sure the other charge schedules are disabled in case user changed them
hasChanged = hasChanged || c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[1])
hasChanged = hasChanged || c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[2])
// post new schedule, but only if something changed to avoid unnecessary API calls
if hasChanged {
res, err := c.api.UpdateSchedule(c.vin, c.pin, stat.EvInfo.Schedules)
if err != nil {
return fmt.Errorf("failed to update schedule: %w", err)
}
if res.ResponseStatus != "pending" {
return fmt.Errorf("invalid response status: %s", res.ResponseStatus)
}
c.log.DEBUG.Printf("updated first charge schedule: enable=%v, start=%s, end=%s",
enable, stat.EvInfo.Schedules[0].StartTime, stat.EvInfo.Schedules[0].EndTime)
}
return nil
}
// Helper to set the correct schedule days matching the provided time, to ensure the schedule is only applied for the current day when it's updated
func setScheduleDays(schedule *Schedule, t time.Time) {
weekday := t.Weekday()
schedule.ScheduledDays.Monday = (weekday == time.Monday)
schedule.ScheduledDays.Tuesday = (weekday == time.Tuesday)
schedule.ScheduledDays.Wednesday = (weekday == time.Wednesday)
schedule.ScheduledDays.Thursday = (weekday == time.Thursday)
schedule.ScheduledDays.Friday = (weekday == time.Friday)
schedule.ScheduledDays.Saturday = (weekday == time.Saturday)
schedule.ScheduledDays.Sunday = (weekday == time.Sunday)
}
// configureChargeSchedule configures the provided schedule with the provided start and end time, while ensuring it fits API requirements and avoiding unnecessary.
// It returns true if the schedule was changed and false otherwise.
func (c *Controller) configureChargeSchedule(schedule *Schedule, start time.Time, end time.Time) bool {
const (
minTimeInterval = 5 * time.Minute // Minimum time interval accepted by Fiat API in schedules; used for rounding start and end time to avoid API rejections
timeFormat = "15:04" // Hours & minutes only
defaultEndTime = "23:55" // Default end time to use when enabling charge; this is the last time of the day accepted by the Fiat API
fallbackStartTime = "00:00" // Fallback time for schedules crossing midnight; this is the first time of the day accepted by the Fiat API
)
var hasChanged bool // track if we made any change to the schedule to avoid unnecessary API calls
// Make sure schedule is enabled and of type CHARGE
if schedule.ScheduleType != "CHARGE" || !schedule.EnableScheduleType {
schedule.ScheduleType = "CHARGE"
schedule.EnableScheduleType = true
schedule.CabinPriority = false
schedule.ChargeToFull = false
schedule.RepeatSchedule = true
hasChanged = true
c.log.DEBUG.Printf("schedule type changed to CHARGE and enabled")
}
// Update start only if provided (non-zero)
if !start.IsZero() {
// round to 5 minutes to avoid API rejections, and allow trying to start in few minutes in the past to start as soon as possible
newStartStr := start.Round(minTimeInterval).Format(timeFormat)
// Update only if different from current
if newStartStr != schedule.StartTime {
schedule.StartTime = newStartStr
schedule.EndTime = defaultEndTime // Set default end time when enabling charge to avoid API rejections for schedules without end time
setScheduleDays(schedule, start) // Set schedule days matching the provided start time to ensure the schedule is only applied for the current day when it's updated
hasChanged = true
c.log.DEBUG.Printf("set charge schedule start: %s with default end time: %s", schedule.StartTime, schedule.EndTime)
} else if schedule.StartTime == schedule.EndTime {
// If start and end are the same, it means we previously stop around the same time it starts.
// We want to start charge again => we need to set end time to default value to make sure the schedule is enabled
schedule.EndTime = defaultEndTime // Set default end time when enabling charge to avoid API rejections for schedules without end time
setScheduleDays(schedule, start) // Set schedule days matching the provided start time to ensure the schedule is only applied for the current day when it's updated
hasChanged = true
c.log.DEBUG.Printf("schedule re-enabled because start and end times were equal")
}
}
// Update end only if provided (non-zero)
if !end.IsZero() {
// round to 5 minutes to avoid API rejections, and allow some delay to stop charge (the round down is the delay)
newEndStr := end.Round(minTimeInterval).Format(timeFormat)
// Update only if different from current
if newEndStr != schedule.EndTime {
schedule.EndTime = newEndStr
hasChanged = true
c.log.DEBUG.Printf("set charge schedule end: %s", schedule.EndTime)
}
}
// If one of the time changed, make sure the schedule is always consistent even in edge cases.
if (!start.IsZero() || !end.IsZero()) && hasChanged {
// To ensure proper comparison of times, we need to parse them back from string to time.Time.
chkStart, err1 := time.Parse(timeFormat, schedule.StartTime)
chkEnd, err2 := time.Parse(timeFormat, schedule.EndTime)
if err1 != nil || err2 != nil {
c.log.WARN.Printf("failed to parse schedule times: start=%v, end=%v", err1, err2)
if err1 != nil {
// If start time cannot be parsed, set to fallback value
schedule.StartTime = fallbackStartTime
setScheduleDays(schedule, end) // Set schedule days matching the provided end time to ensure the schedule is only applied for the current day when it's updated
hasChanged = true
c.log.DEBUG.Printf("set charge schedule start to fallback value %s due to parse error", fallbackStartTime)
}
if err2 != nil {
// If end time cannot be parsed, set to default end time
schedule.EndTime = defaultEndTime
hasChanged = true
c.log.DEBUG.Printf("set charge schedule end to default value %s due to parse error", defaultEndTime)
}
} else if chkStart.After(chkEnd) {
// If start time is after end time (can only happen when setting end), set start time to fallback value to avoid API rejections for schedules crossing midnight
c.log.DEBUG.Printf("start time %s is after end time %s, setting start time to fallback value %s", schedule.StartTime, schedule.EndTime, fallbackStartTime)
schedule.StartTime = fallbackStartTime
setScheduleDays(schedule, end) // Set schedule days matching the provided end time to ensure the schedule is only applied for the current day when it's updated
hasChanged = true
}
}
return hasChanged
}
// disableConflictingChargeSchedule makes sure the provided schedule is disabled if it's of type CHARGE to avoid conflicts between schedules and potential API rejections for conflicting schedules. It returns true if the schedule was changed and false otherwise.
func (c *Controller) disableConflictingChargeSchedule(schedule *Schedule) bool {
// make sure the other charge schedules are disabled in case user changed them
if schedule.ScheduleType == "CHARGE" && schedule.EnableScheduleType {
schedule.EnableScheduleType = false
c.log.DEBUG.Printf("disabled charge schedule other than the first one to avoid conflicts")
return true // schedule was changed
}
return false // schedule was not changed
}
var _ api.Resurrector = (*Controller)(nil)
func (c *Controller) WakeUp() error {
if c.pin == "" {
c.log.DEBUG.Printf("vehicle cannot be woken up: no PIN provided")
return nil
}
// get current schedule status from provider (cached)
stat, err := c.pvd.statusG()
if err == nil && stat.EvInfo != nil && stat.EvInfo.Schedules != nil && len(stat.EvInfo.Schedules) > 0 &&
stat.EvInfo.Schedules[0].EnableScheduleType && stat.EvInfo.Schedules[0].ScheduleType == "CHARGE" {
// If the first schedule is already enabled for charge, don't go further to avoid chargeNow forcing immediate charge start and messing up with schedules
c.log.DEBUG.Printf("vehicle wakeup skipped because charge schedule is already enabled, to avoid conflicts with schedules")
return nil
}
// No charge schedule is set and we need to wakeup the vehicle as charge is not starting => let's call ChargeNow to start the charge
res, err := c.api.ChargeNow(c.vin, c.pin)
if err != nil {
return fmt.Errorf("charge now call failed: %w", err)
}
if res.ResponseStatus != "pending" {
return fmt.Errorf("invalid response status: %s", res.ResponseStatus)
}
c.log.DEBUG.Printf("vehicle wakeup triggered successfully with charge now action")
return nil
}