diff --git a/app.go b/app.go index 74b277b..20e59ec 100644 --- a/app.go +++ b/app.go @@ -25,6 +25,7 @@ type App struct { state *State schedules pq.PriorityQueue + intervals pq.PriorityQueue entityListeners map[string][]*EntityListener entityListenersId int64 eventListeners map[string][]*EventListener @@ -36,6 +37,11 @@ See https://pkg.go.dev/time#ParseDuration for all valid time units. */ type DurationString string +/* +TimeString is a 24-hr format time "HH:MM" such as "07:30". +*/ +type TimeString string + type timeRange struct { start time.Time end time.Time @@ -62,6 +68,7 @@ func NewApp(connString string) *App { service: service, state: state, schedules: pq.New(), + intervals: pq.New(), entityListeners: map[string][]*EntityListener{}, eventListeners: map[string][]*EventListener{}, } @@ -73,41 +80,41 @@ func (a *App) Cleanup() { } } -func (a *App) RegisterSchedules(schedules ...Schedule) { +func (a *App) RegisterSchedules(schedules ...DailySchedule) { for _, s := range schedules { // realStartTime already set for sunset/sunrise if s.isSunrise || s.isSunset { - a.schedules.Insert(s, float64(s.realStartTime.Unix())) + s.nextRunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset).Carbon2Time() + a.schedules.Insert(s, float64(s.nextRunTime.Unix())) continue } - if s.frequency == 0 { - panic("A schedule must use either Daily() or Every() when built.") - } - - now := time.Now() - // TODO: on the day that daylight savings starts/ends, - // this results in schedules being an hour late or hour early because - // StartOfDay() occurs before the switch. This happens if you start - // gome assistant on this day, and VERIFY I think will persist until you - // start it again. Sunrise/sunset should be unaffected since those come - // from HA. - // - // IDEA: splitting schedule into Interval and DailySchedule could address this on schedule - // side, by using SetTime instead of Add(time.Duration). - startTime := carbon.Now().StartOfDay().Carbon2Time() - // apply offset if set - if s.offset.Minutes() > 0 { - startTime = startTime.Add(s.offset) - } + now := carbon.Now() + startTime := carbon.Now().SetTimeMilli(s.hour, s.minute, 0, 0) // advance first scheduled time by frequency until it is in the future - for startTime.Before(now) { - startTime = startTime.Add(s.frequency) + if startTime.Lt(now) { + startTime = startTime.AddDay() } - s.realStartTime = startTime - a.schedules.Insert(s, float64(startTime.Unix())) + s.nextRunTime = startTime.Carbon2Time() + a.schedules.Insert(s, float64(startTime.Carbon2Time().Unix())) + } +} + +func (a *App) RegisterIntervals(intervals ...Interval) { + for _, i := range intervals { + if i.frequency == 0 { + panic("A schedule must use either set frequency via Every().") + } + + i.nextRunTime = internal.ParseTime(string(i.startTime)).Carbon2Time() + now := time.Now() + for i.nextRunTime.Before(now) { + i.nextRunTime = i.nextRunTime.Add(i.frequency) + } + fmt.Println(i) + a.intervals.Insert(i, float64(i.nextRunTime.Unix())) } } @@ -181,8 +188,9 @@ func (a *App) Start() { log.Default().Println("Starting", a.schedules.Len(), "schedules") log.Default().Println("Starting", len(a.entityListeners), "entity listeners") log.Default().Println("Starting", len(a.eventListeners), "event listeners") - // schedules + go runSchedules(a) + go runIntervals(a) // subscribe to state_changed events id := internal.GetId() diff --git a/eventListener.go b/eventListener.go index a85586d..ecb0cea 100644 --- a/eventListener.go +++ b/eventListener.go @@ -21,7 +21,6 @@ type EventListener struct { exceptionRanges []timeRange } -// TODO: add state object as second arg type EventListenerCallback func(*Service, *State, EventData) type EventData struct { diff --git a/example/example.go b/example/example.go index 69e6501..1db6701 100644 --- a/example/example.go +++ b/example/example.go @@ -19,17 +19,15 @@ func main() { Build() _11pmSched := ga. - NewSchedule(). + NewDailySchedule(). Call(lightsOut). - Daily(). At("23:00"). Build() _30minsBeforeSunrise := ga. - NewSchedule(). + NewDailySchedule(). Call(sunriseSched). - Daily(). - Sunrise(app, "-30m"). + Sunrise("-30m"). Build() zwaveEventListener := ga. @@ -39,8 +37,7 @@ func main() { Build() app.RegisterEntityListeners(pantryDoor) - app.RegisterSchedules(_11pmSched) - app.RegisterSchedules(_30minsBeforeSunrise) + app.RegisterSchedules(_11pmSched, _30minsBeforeSunrise) app.RegisterEventListeners(zwaveEventListener) app.Start() diff --git a/internal/internal.go b/internal/internal.go index 92812ea..238992d 100644 --- a/internal/internal.go +++ b/internal/internal.go @@ -3,6 +3,8 @@ package internal import ( "fmt" "log" + "reflect" + "runtime" "time" "github.com/golang-module/carbon" @@ -15,12 +17,13 @@ func GetId() int64 { return id } +// Parses a HH:MM string. func ParseTime(s string) carbon.Carbon { t, err := time.Parse("15:04", s) if err != nil { log.Fatalf("Failed to parse time string \"%s\"; format must be HH:MM.", s) } - return carbon.Now().StartOfDay().SetHour(t.Hour()).SetMinute(t.Minute()) + return carbon.Now().SetTimeMilli(t.Hour(), t.Minute(), 0, 0) } func ParseDuration(s string) time.Duration { @@ -30,3 +33,7 @@ func ParseDuration(s string) time.Duration { } return d } + +func GetFunctionName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} diff --git a/interval.go b/interval.go new file mode 100644 index 0000000..14dcde8 --- /dev/null +++ b/interval.go @@ -0,0 +1,158 @@ +package gomeassistant + +import ( + "fmt" + "time" + + "github.com/saml-dev/gome-assistant/internal" +) + +type IntervalCallback func(*Service, *State) + +type Interval struct { + frequency time.Duration + callback IntervalCallback + startTime TimeString + endTime TimeString + nextRunTime time.Time + + exceptionDays []time.Time + exceptionRanges []timeRange +} + +func (i Interval) Hash() string { + return fmt.Sprint(i.startTime, i.endTime, i.frequency, i.callback, i.exceptionDays, i.exceptionRanges) +} + +// Call +type intervalBuilder struct { + interval Interval +} + +// Every +type intervalBuilderCall struct { + interval Interval +} + +// Offset, ExceptionDay, ExceptionRange +type intervalBuilderEnd struct { + interval Interval +} + +func NewInterval() intervalBuilder { + return intervalBuilder{ + Interval{ + frequency: 0, + startTime: "00:00", + endTime: "00:00", + }, + } +} + +func (i Interval) String() string { + return fmt.Sprintf("Interval{ call %q every %s%s%s }", + internal.GetFunctionName(i.callback), + i.frequency, + formatStartOrEndString(i.startTime /* isStart = */, true), + formatStartOrEndString(i.endTime /* isStart = */, false), + ) +} + +func formatStartOrEndString(s TimeString, isStart bool) string { + if s == "00:00" { + return "" + } + if isStart { + return fmt.Sprintf(" starting at %s", s) + } else { + return fmt.Sprintf(" ending at %s", s) + } +} + +func (sb intervalBuilder) Call(callback IntervalCallback) intervalBuilderCall { + sb.interval.callback = callback + return intervalBuilderCall(sb) +} + +// Takes a DurationString ("2h", "5m", etc) to set the frequency of the interval. +func (sb intervalBuilderCall) Every(s DurationString) intervalBuilderEnd { + d := internal.ParseDuration(string(s)) + sb.interval.frequency = d + return intervalBuilderEnd(sb) +} + +// Takes a TimeString ("HH:MM") when this interval will start running for the day. +func (sb intervalBuilderEnd) StartingAt(s TimeString) intervalBuilderEnd { + sb.interval.startTime = s + return sb +} + +// Takes a TimeString ("HH:MM") when this interval will stop running for the day. +func (sb intervalBuilderEnd) EndingAt(s TimeString) intervalBuilderEnd { + sb.interval.endTime = s + return sb +} + +func (sb intervalBuilderEnd) ExceptionDay(t time.Time) intervalBuilderEnd { + sb.interval.exceptionDays = append(sb.interval.exceptionDays, t) + return sb +} + +func (sb intervalBuilderEnd) ExceptionRange(start, end time.Time) intervalBuilderEnd { + sb.interval.exceptionRanges = append(sb.interval.exceptionRanges, timeRange{start, end}) + return sb +} + +func (sb intervalBuilderEnd) Build() Interval { + return sb.interval +} + +// app.Start() functions +func runIntervals(a *App) { + if a.intervals.Len() == 0 { + return + } + + for { + i := popInterval(a) + + // run callback for all intervals before now in case they overlap + for i.nextRunTime.Before(time.Now()) { + i.maybeRunCallback(a) + requeueInterval(a, i) + + i = popInterval(a) + } + + time.Sleep(time.Until(i.nextRunTime)) + i.maybeRunCallback(a) + requeueInterval(a, i) + } +} + +func (i Interval) maybeRunCallback(a *App) { + if c := checkStartEndTime(i.startTime /* isStart = */, true); c.fail { + return + } + if c := checkStartEndTime(i.endTime /* isStart = */, false); c.fail { + return + } + if c := checkExceptionDays(i.exceptionDays); c.fail { + return + } + if c := checkExceptionRanges(i.exceptionRanges); c.fail { + return + } + go i.callback(a.service, a.state) +} + +func popInterval(a *App) Interval { + i, _ := a.intervals.Pop() + return i.(Interval) +} + +func requeueInterval(a *App, i Interval) { + i.nextRunTime = i.nextRunTime.Add(i.frequency) + + a.intervals.Insert(i, float64(i.nextRunTime.Unix())) +} diff --git a/listeners.go b/listeners.go index c1618b9..c7dbd1e 100644 --- a/listeners.go +++ b/listeners.go @@ -4,6 +4,7 @@ import ( "time" "github.com/golang-module/carbon" + "github.com/saml-dev/gome-assistant/internal" i "github.com/saml-dev/gome-assistant/internal" ) @@ -85,3 +86,24 @@ func checkExceptionRanges(eList []timeRange) conditionCheck { } return cc } + +func checkStartEndTime(s TimeString, isStart bool) conditionCheck { + cc := conditionCheck{fail: false} + // pass immediately if default + if s == "00:00" { + return cc + } + + now := time.Now() + parsedTime := internal.ParseTime(string(s)).Carbon2Time() + if isStart { + if parsedTime.After(now) { + cc.fail = true + } + } else { + if parsedTime.Before(now) { + cc.fail = true + } + } + return cc +} diff --git a/schedule.go b/schedule.go index b6b006a..393732b 100644 --- a/schedule.go +++ b/schedule.go @@ -2,8 +2,6 @@ package gomeassistant import ( "fmt" - "reflect" - "runtime" "time" "github.com/golang-module/carbon" @@ -12,11 +10,14 @@ import ( type ScheduleCallback func(*Service, *State) -type Schedule struct { - frequency time.Duration - callback ScheduleCallback - offset time.Duration - realStartTime time.Time +type DailySchedule struct { + // 0-23 + hour int + // 0-59 + minute int + + callback ScheduleCallback + nextRunTime time.Time isSunrise bool isSunset bool @@ -26,59 +27,41 @@ type Schedule struct { exceptionRanges []timeRange } -func (s Schedule) Hash() string { - return fmt.Sprint(s.offset, s.frequency, s.callback) +func (s DailySchedule) Hash() string { + return fmt.Sprint(s.hour, s.minute, s.callback) } type scheduleBuilder struct { - schedule Schedule + schedule DailySchedule } type scheduleBuilderCall struct { - schedule Schedule -} - -type scheduleBuilderDaily struct { - schedule Schedule -} - -type scheduleBuilderCustom struct { - schedule Schedule + schedule DailySchedule } type scheduleBuilderEnd struct { - schedule Schedule + schedule DailySchedule } -func NewSchedule() scheduleBuilder { +func NewDailySchedule() scheduleBuilder { return scheduleBuilder{ - Schedule{ - frequency: 0, - offset: 0, + DailySchedule{ + hour: 0, + minute: 0, + sunOffset: "0s", }, } } -func (s Schedule) String() string { - return fmt.Sprintf("Schedule{ call %q %s %s }", - getFunctionName(s.callback), - frequencyToString(s.frequency), - offsetToString(s), +func (s DailySchedule) String() string { + return fmt.Sprintf("Schedule{ call %q daily at %s }", + internal.GetFunctionName(s.callback), + stringHourMinute(s.hour, s.minute), ) } -func offsetToString(s Schedule) string { - if s.frequency.Hours() == 24 { - return fmt.Sprintf("%02d:%02d", int(s.offset.Hours()), int(s.offset.Minutes())%60) - } - return s.offset.String() -} - -func frequencyToString(d time.Duration) string { - if d.Hours() == 24 { - return "daily at" - } - return "every " + d.String() + " with offset" +func stringHourMinute(hour, minute int) string { + return fmt.Sprintf("%02d:%02d", hour, minute) } func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall { @@ -86,62 +69,30 @@ func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall { return scheduleBuilderCall(sb) } -func (sb scheduleBuilderCall) Daily() scheduleBuilderDaily { - sb.schedule.frequency = time.Hour * 24 - return scheduleBuilderDaily(sb) -} - -// At takes a string 24hr format time like "15:30". -func (sb scheduleBuilderDaily) At(s string) scheduleBuilderEnd { +// At takes a string in 24hr format time like "15:30". +func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd { t := internal.ParseTime(s) - sb.schedule.offset = time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute + sb.schedule.hour = t.Hour() + sb.schedule.minute = t.Minute() return scheduleBuilderEnd(sb) } -// Sunrise takes an app pointer and an optional duration string that is passed to time.ParseDuration. +// Sunrise takes an optional duration string that is passed to time.ParseDuration. // Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration // for full list. -func (sb scheduleBuilderDaily) Sunrise(a *App, offset ...DurationString) scheduleBuilderEnd { - sb.schedule.realStartTime = getSunriseSunsetFromApp(a, true, offset...).Carbon2Time() +func (sb scheduleBuilderCall) Sunrise(offset ...DurationString) scheduleBuilderEnd { sb.schedule.isSunrise = true return scheduleBuilderEnd(sb) } -// Sunset takes an app pointer and an optional duration string that is passed to time.ParseDuration. +// Sunset takes an optional duration string that is passed to time.ParseDuration. // Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration // for full list. -func (sb scheduleBuilderDaily) Sunset(a *App, offset ...DurationString) scheduleBuilderEnd { - sb.schedule.realStartTime = getSunriseSunsetFromApp(a, false, offset...).Carbon2Time() +func (sb scheduleBuilderCall) Sunset(offset ...DurationString) scheduleBuilderEnd { sb.schedule.isSunset = true return scheduleBuilderEnd(sb) } -func (sb scheduleBuilderCall) Every(s DurationString) scheduleBuilderCustom { - d := internal.ParseDuration(string(s)) - sb.schedule.frequency = d - return scheduleBuilderCustom(sb) -} - -func (sb scheduleBuilderCustom) Offset(s DurationString) scheduleBuilderEnd { - d := internal.ParseDuration(string(s)) - sb.schedule.offset = d - return scheduleBuilderEnd(sb) -} - -func (sb scheduleBuilderCustom) ExceptionDay(t time.Time) scheduleBuilderCustom { - sb.schedule.exceptionDays = append(sb.schedule.exceptionDays, t) - return sb -} - -func (sb scheduleBuilderCustom) ExceptionRange(start, end time.Time) scheduleBuilderCustom { - sb.schedule.exceptionRanges = append(sb.schedule.exceptionRanges, timeRange{start, end}) - return sb -} - -func (sb scheduleBuilderCustom) Build() Schedule { - return sb.schedule -} - func (sb scheduleBuilderEnd) ExceptionDay(t time.Time) scheduleBuilderEnd { sb.schedule.exceptionDays = append(sb.schedule.exceptionDays, t) return sb @@ -152,14 +103,10 @@ func (sb scheduleBuilderEnd) ExceptionRange(start, end time.Time) scheduleBuilde return sb } -func (sb scheduleBuilderEnd) Build() Schedule { +func (sb scheduleBuilderEnd) Build() DailySchedule { return sb.schedule } -func getFunctionName(i interface{}) string { - return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() -} - // app.Start() functions func runSchedules(a *App) { if a.schedules.Len() == 0 { @@ -170,21 +117,21 @@ func runSchedules(a *App) { sched := popSchedule(a) // run callback for all schedules before now in case they overlap - for sched.realStartTime.Before(time.Now()) { - maybeRunCallback(a, sched) + for sched.nextRunTime.Before(time.Now()) { + sched.maybeRunCallback(a) requeueSchedule(a, sched) sched = popSchedule(a) } - fmt.Println("Next schedule:", sched.realStartTime) - time.Sleep(time.Until(sched.realStartTime)) - maybeRunCallback(a, sched) + fmt.Println("Next schedule:", sched.nextRunTime) + time.Sleep(time.Until(sched.nextRunTime)) + sched.maybeRunCallback(a) requeueSchedule(a, sched) } } -func maybeRunCallback(a *App, s Schedule) { +func (s DailySchedule) maybeRunCallback(a *App) { if c := checkExceptionDays(s.exceptionDays); c.fail { return } @@ -194,31 +141,32 @@ func maybeRunCallback(a *App, s Schedule) { go s.callback(a.service, a.state) } -func popSchedule(a *App) Schedule { +func popSchedule(a *App) DailySchedule { _sched, _ := a.schedules.Pop() - return _sched.(Schedule) + return _sched.(DailySchedule) } -func requeueSchedule(a *App, s Schedule) { +func requeueSchedule(a *App, s DailySchedule) { if s.isSunrise || s.isSunset { var nextSunTime carbon.Carbon - if s.sunOffset != "" { + // "0s" is default value + if s.sunOffset != "0s" { nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset) } else { nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise) } // this is true when there is a negative offset, so schedule runs before sunset/sunrise and - // HA still shows today's sunset as next sunset. Just add 24h as a default handler + // HA still shows today's sunset as next sunset. Just add 1 day as a default handler // since we can't get tomorrow's sunset from HA at this point. if nextSunTime.IsToday() { - nextSunTime = nextSunTime.AddHours(24) + nextSunTime = nextSunTime.AddDay() } - s.realStartTime = nextSunTime.Carbon2Time() + s.nextRunTime = nextSunTime.Carbon2Time() } else { - s.realStartTime = s.realStartTime.Add(s.frequency) + s.nextRunTime = carbon.Time2Carbon(s.nextRunTime).AddDay().Carbon2Time() } - a.schedules.Insert(s, float64(s.realStartTime.Unix())) + a.schedules.Insert(s, float64(s.nextRunTime.Unix())) }