Fiat: improve charge scheduling and refresh logic (#27654)

This commit is contained in:
FraBoCH 2026-03-01 10:37:59 +01:00 • committed by GitHub
parent 87c5be3f55
commit 1a8e93d7c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 398 additions and 31 deletions

View file

@ -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")
}
@ -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

View file

@ -53,41 +53,40 @@ 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()
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; 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])
hasChanged = hasChanged || c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[1])
hasChanged = hasChanged || 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)
// 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 err
return nil
}
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()
// 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)
@ -97,25 +96,133 @@ func (c *Controller) configureChargeSchedule(schedule *Schedule) {
schedule.ScheduledDays.Sunday = (weekday == time.Sunday)
}
func (c *Controller) disableConflictingChargeSchedule(schedule *Schedule) {
// 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("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)
// 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
}
return err
// 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
}

View file

@ -0,0 +1,259 @@
package fiat
import (
"testing"
"time"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
)
// makeTime constructs a time.Time on an arbitrary fixed date in UTC
// using the provided hour and minute. It is used to simulate "now" values
// in unit tests without caring about the actual day.
func makeTime(t ...int) time.Time {
h, m, s := 0, 0, 0
if len(t) > 0 {
h = t[0]
}
if len(t) > 1 {
m = t[1]
}
if len(t) > 2 {
s = t[2]
}
return time.Date(2026, 7, 8, h, m, s, 0, time.UTC)
}
func newController() *Controller {
return &Controller{log: util.NewLogger("fiat-test")}
}
func TestConfigureChargeSchedule_NominalChargeSession(t *testing.T) {
// if the requested end time is before the current end, we should still
// honour it immediately (e.g. user shortened the charge window)
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
}
// Start charge
has := c.configureChargeSchedule(schedule, makeTime(10, 04), time.Time{})
assert.True(t, has)
assert.Equal(t, "10:05", schedule.StartTime, "start should be rounded to 5-minute")
assert.Equal(t, "23:55", schedule.EndTime, "end time should always set to 23:55 when starting charge")
assert.True(t, schedule.ScheduledDays.Wednesday, "schedule should be enabled for Wednesday")
assert.False(t, schedule.ScheduledDays.Monday || schedule.ScheduledDays.Tuesday || schedule.ScheduledDays.Thursday ||
schedule.ScheduledDays.Friday || schedule.ScheduledDays.Saturday || schedule.ScheduledDays.Sunday, "schedule should be false for all other days")
// Stop charge few hours later on the same day
has = c.configureChargeSchedule(schedule, time.Time{}, makeTime(14, 02))
assert.True(t, has)
assert.Equal(t, "10:05", schedule.StartTime, "start should not change when stopping charge")
assert.Equal(t, "14:00", schedule.EndTime, "end time should be rounded to 5-minute when stopping charge")
assert.True(t, schedule.ScheduledDays.Wednesday, "schedule should be enabled for Wednesday")
assert.False(t, schedule.ScheduledDays.Monday || schedule.ScheduledDays.Tuesday || schedule.ScheduledDays.Thursday ||
schedule.ScheduledDays.Friday || schedule.ScheduledDays.Saturday || schedule.ScheduledDays.Sunday, "schedule should be false for all other days")
}
func TestConfigureChargeSchedule_ScheduleTypeEnabling(t *testing.T) {
// if schedule type is not CHARGE or is disabled, it should be corrected
// and other settings should be initialized
c := newController()
schedule := &Schedule{
ScheduleType: "PRECONDITIONING",
EnableScheduleType: false,
StartTime: "10:00",
EndTime: "20:00",
}
// Start charge must enable schedule type and set it to CHARGE, and reset other settings to defaults
has := c.configureChargeSchedule(schedule, makeTime(19, 42), time.Time{})
assert.True(t, has)
assert.Equal(t, "CHARGE", schedule.ScheduleType, "schedule type should be set to CHARGE")
assert.True(t, schedule.EnableScheduleType, "schedule should be enabled")
assert.False(t, schedule.CabinPriority, "cabin priority should be false")
assert.False(t, schedule.ChargeToFull, "charge to full should be false")
assert.True(t, schedule.RepeatSchedule, "repeat schedule should be true")
}
func TestConfigureChargeSchedule_NoChangeWhenNoEndOrStart(t *testing.T) {
// if neither start nor end is provided, schedule should not be modified
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
StartTime: "10:00",
EndTime: "19:40",
}
has := c.configureChargeSchedule(schedule, time.Time{}, time.Time{})
assert.False(t, has)
assert.Equal(t, "10:00", schedule.StartTime)
assert.Equal(t, "19:40", schedule.EndTime)
}
func TestConfigureChargeSchedule_ParseErrorStartOnly(t *testing.T) {
// if only the start time fails to parse, it should be set to fallback
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
StartTime: "bad_time",
EndTime: "23:55",
}
// Set end time with a invalid start time
has := c.configureChargeSchedule(schedule, time.Time{}, makeTime(19, 43))
assert.True(t, has)
assert.Equal(t, "00:00", schedule.StartTime)
assert.Equal(t, "19:45", schedule.EndTime)
}
func TestConfigureChargeSchedule_ScheduledDaysReset(t *testing.T) {
// when schedule is changed, scheduled days should be set to only the
// current day to avoid undesired charge in the future
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
StartTime: "10:00",
EndTime: "19:00",
}
schedule.ScheduledDays.Monday = false
schedule.ScheduledDays.Tuesday = false
schedule.ScheduledDays.Wednesday = false
schedule.ScheduledDays.Thursday = false
schedule.ScheduledDays.Friday = false
schedule.ScheduledDays.Saturday = true
schedule.ScheduledDays.Sunday = true
// Friday, 2026-02-27
has := c.configureChargeSchedule(schedule, time.Date(2026, 2, 27, 19, 43, 0, 0, time.UTC), time.Time{})
assert.True(t, has)
// Only Friday should be enabled after start
assert.False(t, schedule.ScheduledDays.Monday, "Monday should be disabled")
assert.False(t, schedule.ScheduledDays.Tuesday, "Tuesday should be disabled")
assert.False(t, schedule.ScheduledDays.Wednesday, "Wednesday should be disabled")
assert.False(t, schedule.ScheduledDays.Thursday, "Thursday should be disabled")
assert.True(t, schedule.ScheduledDays.Friday, "Friday should be enabled")
assert.False(t, schedule.ScheduledDays.Saturday, "Saturday should be disabled")
assert.False(t, schedule.ScheduledDays.Sunday, "Sunday should be disabled")
// Set end time, which should not change the scheduled days as they were already set to current day on start time update
has = c.configureChargeSchedule(schedule, time.Time{}, time.Date(2026, 2, 27, 19, 50, 0, 0, time.UTC))
assert.True(t, has)
assert.False(t, schedule.ScheduledDays.Monday, "Monday should be disabled")
assert.False(t, schedule.ScheduledDays.Tuesday, "Tuesday should be disabled")
assert.False(t, schedule.ScheduledDays.Wednesday, "Wednesday should be disabled")
assert.False(t, schedule.ScheduledDays.Thursday, "Thursday should be disabled")
assert.True(t, schedule.ScheduledDays.Friday, "Friday should be enabled")
assert.False(t, schedule.ScheduledDays.Saturday, "Saturday should be disabled")
assert.False(t, schedule.ScheduledDays.Sunday, "Sunday should be disabled")
// Next day is Saturday, 2026-02-28: if we start the schedule again on the next day, only Saturday should be enabled
has = c.configureChargeSchedule(schedule, time.Date(2026, 2, 28, 8, 12, 0, 0, time.UTC), time.Time{})
assert.True(t, has, "expected schedule to be updated for new day")
// Only Saturday should be enabled
assert.False(t, schedule.ScheduledDays.Monday, "Monday should be disabled")
assert.False(t, schedule.ScheduledDays.Tuesday, "Tuesday should be disabled")
assert.False(t, schedule.ScheduledDays.Wednesday, "Wednesday should be disabled")
assert.False(t, schedule.ScheduledDays.Thursday, "Thursday should be disabled")
assert.False(t, schedule.ScheduledDays.Friday, "Friday should be disabled")
assert.True(t, schedule.ScheduledDays.Saturday, "Saturday should be enabled")
assert.False(t, schedule.ScheduledDays.Sunday, "Sunday should be disabled")
}
func TestConfigureChargeSchedule_CrossingMidnight(t *testing.T) {
// if start time is after end time (schedule crossing midnight), start
// should be set to the fallback value "00:00" to avoid API rejections
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
StartTime: "22:00",
EndTime: "23:55",
}
// Fix weekday for test is a Wednesday, so for this test the previous scheduled day is Tuesday
schedule.ScheduledDays.Monday = false
schedule.ScheduledDays.Tuesday = true
schedule.ScheduledDays.Wednesday = false
schedule.ScheduledDays.Thursday = false
schedule.ScheduledDays.Friday = false
schedule.ScheduledDays.Saturday = false
schedule.ScheduledDays.Sunday = false
// trigger validation by changing the end time (which will be parsed and compared against the start time)
now := makeTime(7, 15)
has := c.configureChargeSchedule(schedule, time.Time{}, now)
assert.True(t, has)
assert.Equal(t, "00:00", schedule.StartTime, "start should be set to fallback when after end")
assert.Equal(t, "07:15", schedule.EndTime)
assert.False(t, schedule.ScheduledDays.Tuesday, "Tuesday should be disabled")
assert.True(t, schedule.ScheduledDays.Wednesday, "Wednesday should be enabled")
}
func TestConfigureChargeSchedule_AvoidEndlessEndPostpone(t *testing.T) {
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
StartTime: "10:00",
EndTime: "19:40",
}
// when now is only one minute past the original end, end should not be postponed
has := c.configureChargeSchedule(schedule, time.Time{}, makeTime(19, 41, 45))
assert.False(t, has, "unexpected change for too-soon update")
assert.Equal(t, "19:40", schedule.EndTime)
// once we cross the rouding threshold the schedule should be updated to the next 5‑minute boundary
schedule.EndTime = "19:40" // reset for clarity
has = c.configureChargeSchedule(schedule, time.Time{}, makeTime(19, 43))
assert.True(t, has, "expected schedule to be bumped")
assert.Equal(t, "19:45", schedule.EndTime)
}
func TestConfigureChargeSchedule_StartStopStartAgainInShortTime(t *testing.T) {
// if start time equals end time, it means charge was stopped right before or right after the schedule start time.
// If after this, we want to start charge again, we need to make sure the schedule is correctly re-enabled
// by setting end time to default value when enabling charge.
c := newController()
schedule := &Schedule{
ScheduleType: "CHARGE",
EnableScheduleType: true,
}
// Set both start and end to values that round to the same time (19:05).
// First update: set start to 19:03 which rounds to 19:05
has := c.configureChargeSchedule(schedule, makeTime(19, 3), time.Time{})
assert.True(t, has)
assert.Equal(t, "19:05", schedule.StartTime)
assert.Equal(t, "23:55", schedule.EndTime) // Default end time should always be set when enabling charge
// Second update: set end to 19:04 which also rounds to 19:05
has = c.configureChargeSchedule(schedule, time.Time{}, makeTime(19, 4))
assert.True(t, has)
assert.Equal(t, "19:05", schedule.StartTime)
assert.Equal(t, "19:05", schedule.EndTime)
// Start charge again: few seconds before schedule start time
has = c.configureChargeSchedule(schedule, makeTime(19, 4, 30), time.Time{})
assert.True(t, has)
assert.Equal(t, "19:05", schedule.StartTime)
assert.Equal(t, "23:55", schedule.EndTime) // Default end time should always be set when enabling charge
// Start charge again: few seconds after schedule start time
has = c.configureChargeSchedule(schedule, makeTime(19, 5, 15), time.Time{})
assert.False(t, has, "unexpected change when start again right after schedule start")
assert.Equal(t, "19:05", schedule.StartTime)
assert.Equal(t, "23:55", schedule.EndTime) // Default end time should always be set when enabling charge
}