Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion vehicle/fiat/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ func (v *API) Action(vin, pin, action, cmd string) (ActionResponse, error) {
return res, err
}

// Warning: calling ChargeNow will start charging immediately and schedules will not be able to stop the charging.
func (v *API) ChargeNow(vin, pin string) (ActionResponse, error) {
return v.Action(vin, pin, "ev/chargenow", "CNOW")
}
Expand Down Expand Up @@ -189,7 +190,7 @@ func (v *API) UpdateSchedule(vin, pin string, schedules []Schedule) (ActionRespo
}

if err == nil && res.Message != "" {
err = fmt.Errorf("action schedules: %s", res.Message)
err = fmt.Errorf("unable to set action schedules: %s", res.Message)
}

return res, err
Expand Down
191 changes: 143 additions & 48 deletions vehicle/fiat/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ import (
)

type Controller struct {
pvd *Provider
api *API
log *util.Logger
vin string
pin string
pvd *Provider
api *API
log *util.Logger
vin string
pin string
isChargedControlled bool
}

// 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,
pvd: provider,
api: api,
log: log,
vin: vin,
pin: pin,
isChargedControlled: false, // false for now, will be set to true when ChargeEnable is actually called.
}
return impl
}
Expand All @@ -44,6 +46,8 @@ func (c *Controller) ChargeEnable(enable bool) error {
return api.ErrMissingCredentials
}

c.isChargedControlled = true // set to true when ChargeEnable is called for the first time, to indicate that we are actually controlling the charge, this will be used in WakeUp method to decide if we should call ChargeNow or not

// get current schedule status from provider (cached)
stat, err := c.pvd.statusG()
if err != nil {
Expand All @@ -53,69 +57,160 @@ func (c *Controller) ChargeEnable(enable bool) error {
return api.ErrNotAvailable
}

// configure first schedule and make sure it's active
c.configureChargeSchedule(&stat.EvInfo.Schedules[0])
hasChanged := false // Will track if we made any change to the schedule to avoid unnecessary updates through API call
now := time.Now() // Call once and reuse

if enable {
// start charging by updating active charge schedule to start now and end in 12h
stat.EvInfo.Schedules[0].StartTime = time.Now().Format("15:04") // only hour and minutes
stat.EvInfo.Schedules[0].EndTime = time.Now().Add(time.Hour * 12).Format("15:04") // only hour and minutes
// Start charging from now until end of day (23:55)
hasChanged = hasChanged || c.configureChargeSchedule(&stat.EvInfo.Schedules[0], now, time.Time{})
} else {
// stop charging by updating active charge schedule end time to now
stat.EvInfo.Schedules[0].EndTime = time.Now().Format("15:04") // only hour and minutes
// Stop charging: set charge end time to now to stop charging as soon as possible (within the next 5 minutes, due to 5-minute rounding; 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
c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[1])
c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[2])

// post new schedule
res, err := c.api.UpdateSchedule(c.vin, c.pin, stat.EvInfo.Schedules)
if err == nil && res.ResponseStatus != "pending" {
err = fmt.Errorf("invalid response status: %s", res.ResponseStatus)
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.INFO.Printf("updated first charge schedule: enable=%v, start=%s, end=%s",
enable, stat.EvInfo.Schedules[0].StartTime, stat.EvInfo.Schedules[0].EndTime)
}

return err
return nil
}

func roundUpTo(d time.Duration, t time.Time) time.Time {
// Round up time to next d boundary
rt := t.Truncate(d)
if !rt.After(t) {
rt = rt.Add(d)
}
return rt
}

func (c *Controller) configureChargeSchedule(schedule *Schedule) {
// all values are set to be sure no manual change can lead to inconsistencies
schedule.CabinPriority = false
schedule.ChargeToFull = false
schedule.EnableScheduleType = true
schedule.RepeatSchedule = true
schedule.ScheduleType = "CHARGE"

// only enable for current day to avoid undesired charge start in the future
weekday := time.Now().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 changes if times are not significantly different to prevent API rejections for unchanged schedules. 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 up 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
)

hasChanged := false // 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 up to next 5 minutes boundary to avoid API rejections and make sure the schedule will be applied by the vehicle
newStartStr := roundUpTo(minTimeInterval, start).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
hasChanged = true
c.log.DEBUG.Printf("set charge schedule start: %s with default end time: %s", schedule.StartTime, schedule.EndTime)

// only enable for current day to avoid undesired charge start in the future
weekday := start.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)
}
}

// Update end only if provided (non-zero)
if !end.IsZero() {
// round up to next 5 minutes boundary to avoid API rejections and make sure the schedule will be applied by the vehicle
newEndStr := roundUpTo(minTimeInterval, end).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 start time is always before end time (parse both from string to ensure proper comparison)
if (!start.IsZero() || !end.IsZero()) && hasChanged {
chkStart, err1 := time.Parse(timeFormat, schedule.StartTime)
chkEnd, err2 := time.Parse(timeFormat, schedule.EndTime)
if err1 == nil && err2 == nil && chkStart.After(chkEnd) {
// If start time is after end time, set start time to fallback value (00:01) 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
hasChanged = true
} else 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, also set to fallback value
schedule.StartTime = fallbackStartTime
hasChanged = true
c.log.DEBUG.Printf("set charge schedule start to fallback value %s due to parse error", fallbackStartTime)
}
}
}

return hasChanged
}

func (c *Controller) disableConflictingChargeSchedule(schedule *Schedule) {
// 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("pin required for vehicle wakeup")
c.log.DEBUG.Printf("Vehicle cannot be woken up: no PIN provided")
return nil
}

res, err := c.api.ChargeNow(c.vin, c.pin)
if err == nil && res.ResponseStatus != "pending" {
err = fmt.Errorf("invalid response status: %s", res.ResponseStatus)
// Only call ChargeNow to WakeUp if the charger is handling the charging and not the vehicle, otherwise schedules will not work properly.
if !c.isChargedControlled {
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")
} else {
c.log.DEBUG.Printf("vehicle wakeup skipped because charge is controlled by evcc, to avoid conflicts with schedules")
}

return err
return nil
}
Loading