split daily schedule and interval

This commit is contained in:
Sam Lewis
2022-11-13 20:17:00 -05:00
parent 83998b5e7e
commit ced79559e5
7 changed files with 275 additions and 136 deletions

60
app.go
View File

@@ -25,6 +25,7 @@ type App struct {
state *State state *State
schedules pq.PriorityQueue schedules pq.PriorityQueue
intervals pq.PriorityQueue
entityListeners map[string][]*EntityListener entityListeners map[string][]*EntityListener
entityListenersId int64 entityListenersId int64
eventListeners map[string][]*EventListener eventListeners map[string][]*EventListener
@@ -36,6 +37,11 @@ See https://pkg.go.dev/time#ParseDuration for all valid time units.
*/ */
type DurationString string type DurationString string
/*
TimeString is a 24-hr format time "HH:MM" such as "07:30".
*/
type TimeString string
type timeRange struct { type timeRange struct {
start time.Time start time.Time
end time.Time end time.Time
@@ -62,6 +68,7 @@ func NewApp(connString string) *App {
service: service, service: service,
state: state, state: state,
schedules: pq.New(), schedules: pq.New(),
intervals: pq.New(),
entityListeners: map[string][]*EntityListener{}, entityListeners: map[string][]*EntityListener{},
eventListeners: map[string][]*EventListener{}, 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 { for _, s := range schedules {
// realStartTime already set for sunset/sunrise // realStartTime already set for sunset/sunrise
if s.isSunrise || s.isSunset { 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 continue
} }
if s.frequency == 0 { now := carbon.Now()
panic("A schedule must use either Daily() or Every() when built.") startTime := carbon.Now().SetTimeMilli(s.hour, s.minute, 0, 0)
}
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)
}
// advance first scheduled time by frequency until it is in the future // advance first scheduled time by frequency until it is in the future
for startTime.Before(now) { if startTime.Lt(now) {
startTime = startTime.Add(s.frequency) startTime = startTime.AddDay()
} }
s.realStartTime = startTime s.nextRunTime = startTime.Carbon2Time()
a.schedules.Insert(s, float64(startTime.Unix())) 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", a.schedules.Len(), "schedules")
log.Default().Println("Starting", len(a.entityListeners), "entity listeners") log.Default().Println("Starting", len(a.entityListeners), "entity listeners")
log.Default().Println("Starting", len(a.eventListeners), "event listeners") log.Default().Println("Starting", len(a.eventListeners), "event listeners")
// schedules
go runSchedules(a) go runSchedules(a)
go runIntervals(a)
// subscribe to state_changed events // subscribe to state_changed events
id := internal.GetId() id := internal.GetId()

View File

@@ -21,7 +21,6 @@ type EventListener struct {
exceptionRanges []timeRange exceptionRanges []timeRange
} }
// TODO: add state object as second arg
type EventListenerCallback func(*Service, *State, EventData) type EventListenerCallback func(*Service, *State, EventData)
type EventData struct { type EventData struct {

View File

@@ -19,17 +19,15 @@ func main() {
Build() Build()
_11pmSched := ga. _11pmSched := ga.
NewSchedule(). NewDailySchedule().
Call(lightsOut). Call(lightsOut).
Daily().
At("23:00"). At("23:00").
Build() Build()
_30minsBeforeSunrise := ga. _30minsBeforeSunrise := ga.
NewSchedule(). NewDailySchedule().
Call(sunriseSched). Call(sunriseSched).
Daily(). Sunrise("-30m").
Sunrise(app, "-30m").
Build() Build()
zwaveEventListener := ga. zwaveEventListener := ga.
@@ -39,8 +37,7 @@ func main() {
Build() Build()
app.RegisterEntityListeners(pantryDoor) app.RegisterEntityListeners(pantryDoor)
app.RegisterSchedules(_11pmSched) app.RegisterSchedules(_11pmSched, _30minsBeforeSunrise)
app.RegisterSchedules(_30minsBeforeSunrise)
app.RegisterEventListeners(zwaveEventListener) app.RegisterEventListeners(zwaveEventListener)
app.Start() app.Start()

View File

@@ -3,6 +3,8 @@ package internal
import ( import (
"fmt" "fmt"
"log" "log"
"reflect"
"runtime"
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
@@ -15,12 +17,13 @@ func GetId() int64 {
return id return id
} }
// Parses a HH:MM string.
func ParseTime(s string) carbon.Carbon { func ParseTime(s string) carbon.Carbon {
t, err := time.Parse("15:04", s) t, err := time.Parse("15:04", s)
if err != nil { if err != nil {
log.Fatalf("Failed to parse time string \"%s\"; format must be HH:MM.", s) 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 { func ParseDuration(s string) time.Duration {
@@ -30,3 +33,7 @@ func ParseDuration(s string) time.Duration {
} }
return d return d
} }
func GetFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}

158
interval.go Normal file
View File

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

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
"github.com/saml-dev/gome-assistant/internal"
i "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 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
}

View File

@@ -2,8 +2,6 @@ package gomeassistant
import ( import (
"fmt" "fmt"
"reflect"
"runtime"
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
@@ -12,11 +10,14 @@ import (
type ScheduleCallback func(*Service, *State) type ScheduleCallback func(*Service, *State)
type Schedule struct { type DailySchedule struct {
frequency time.Duration // 0-23
hour int
// 0-59
minute int
callback ScheduleCallback callback ScheduleCallback
offset time.Duration nextRunTime time.Time
realStartTime time.Time
isSunrise bool isSunrise bool
isSunset bool isSunset bool
@@ -26,59 +27,41 @@ type Schedule struct {
exceptionRanges []timeRange exceptionRanges []timeRange
} }
func (s Schedule) Hash() string { func (s DailySchedule) Hash() string {
return fmt.Sprint(s.offset, s.frequency, s.callback) return fmt.Sprint(s.hour, s.minute, s.callback)
} }
type scheduleBuilder struct { type scheduleBuilder struct {
schedule Schedule schedule DailySchedule
} }
type scheduleBuilderCall struct { type scheduleBuilderCall struct {
schedule Schedule schedule DailySchedule
}
type scheduleBuilderDaily struct {
schedule Schedule
}
type scheduleBuilderCustom struct {
schedule Schedule
} }
type scheduleBuilderEnd struct { type scheduleBuilderEnd struct {
schedule Schedule schedule DailySchedule
} }
func NewSchedule() scheduleBuilder { func NewDailySchedule() scheduleBuilder {
return scheduleBuilder{ return scheduleBuilder{
Schedule{ DailySchedule{
frequency: 0, hour: 0,
offset: 0, minute: 0,
sunOffset: "0s",
}, },
} }
} }
func (s Schedule) String() string { func (s DailySchedule) String() string {
return fmt.Sprintf("Schedule{ call %q %s %s }", return fmt.Sprintf("Schedule{ call %q daily at %s }",
getFunctionName(s.callback), internal.GetFunctionName(s.callback),
frequencyToString(s.frequency), stringHourMinute(s.hour, s.minute),
offsetToString(s),
) )
} }
func offsetToString(s Schedule) string { func stringHourMinute(hour, minute int) string {
if s.frequency.Hours() == 24 { return fmt.Sprintf("%02d:%02d", hour, minute)
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 (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall { func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
@@ -86,62 +69,30 @@ func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
return scheduleBuilderCall(sb) return scheduleBuilderCall(sb)
} }
func (sb scheduleBuilderCall) Daily() scheduleBuilderDaily { // At takes a string in 24hr format time like "15:30".
sb.schedule.frequency = time.Hour * 24 func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
return scheduleBuilderDaily(sb)
}
// At takes a string 24hr format time like "15:30".
func (sb scheduleBuilderDaily) At(s string) scheduleBuilderEnd {
t := internal.ParseTime(s) 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) 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 // Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
// for full list. // for full list.
func (sb scheduleBuilderDaily) Sunrise(a *App, offset ...DurationString) scheduleBuilderEnd { func (sb scheduleBuilderCall) Sunrise(offset ...DurationString) scheduleBuilderEnd {
sb.schedule.realStartTime = getSunriseSunsetFromApp(a, true, offset...).Carbon2Time()
sb.schedule.isSunrise = true sb.schedule.isSunrise = true
return scheduleBuilderEnd(sb) 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 // Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
// for full list. // for full list.
func (sb scheduleBuilderDaily) Sunset(a *App, offset ...DurationString) scheduleBuilderEnd { func (sb scheduleBuilderCall) Sunset(offset ...DurationString) scheduleBuilderEnd {
sb.schedule.realStartTime = getSunriseSunsetFromApp(a, false, offset...).Carbon2Time()
sb.schedule.isSunset = true sb.schedule.isSunset = true
return scheduleBuilderEnd(sb) 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 { func (sb scheduleBuilderEnd) ExceptionDay(t time.Time) scheduleBuilderEnd {
sb.schedule.exceptionDays = append(sb.schedule.exceptionDays, t) sb.schedule.exceptionDays = append(sb.schedule.exceptionDays, t)
return sb return sb
@@ -152,14 +103,10 @@ func (sb scheduleBuilderEnd) ExceptionRange(start, end time.Time) scheduleBuilde
return sb return sb
} }
func (sb scheduleBuilderEnd) Build() Schedule { func (sb scheduleBuilderEnd) Build() DailySchedule {
return sb.schedule return sb.schedule
} }
func getFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}
// app.Start() functions // app.Start() functions
func runSchedules(a *App) { func runSchedules(a *App) {
if a.schedules.Len() == 0 { if a.schedules.Len() == 0 {
@@ -170,21 +117,21 @@ func runSchedules(a *App) {
sched := popSchedule(a) sched := popSchedule(a)
// run callback for all schedules before now in case they overlap // run callback for all schedules before now in case they overlap
for sched.realStartTime.Before(time.Now()) { for sched.nextRunTime.Before(time.Now()) {
maybeRunCallback(a, sched) sched.maybeRunCallback(a)
requeueSchedule(a, sched) requeueSchedule(a, sched)
sched = popSchedule(a) sched = popSchedule(a)
} }
fmt.Println("Next schedule:", sched.realStartTime) fmt.Println("Next schedule:", sched.nextRunTime)
time.Sleep(time.Until(sched.realStartTime)) time.Sleep(time.Until(sched.nextRunTime))
maybeRunCallback(a, sched) sched.maybeRunCallback(a)
requeueSchedule(a, sched) requeueSchedule(a, sched)
} }
} }
func maybeRunCallback(a *App, s Schedule) { func (s DailySchedule) maybeRunCallback(a *App) {
if c := checkExceptionDays(s.exceptionDays); c.fail { if c := checkExceptionDays(s.exceptionDays); c.fail {
return return
} }
@@ -194,31 +141,32 @@ func maybeRunCallback(a *App, s Schedule) {
go s.callback(a.service, a.state) go s.callback(a.service, a.state)
} }
func popSchedule(a *App) Schedule { func popSchedule(a *App) DailySchedule {
_sched, _ := a.schedules.Pop() _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 { if s.isSunrise || s.isSunset {
var nextSunTime carbon.Carbon var nextSunTime carbon.Carbon
if s.sunOffset != "" { // "0s" is default value
if s.sunOffset != "0s" {
nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset) nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise, s.sunOffset)
} else { } else {
nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise) nextSunTime = getSunriseSunsetFromApp(a, s.isSunrise)
} }
// this is true when there is a negative offset, so schedule runs before sunset/sunrise and // 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. // since we can't get tomorrow's sunset from HA at this point.
if nextSunTime.IsToday() { if nextSunTime.IsToday() {
nextSunTime = nextSunTime.AddHours(24) nextSunTime = nextSunTime.AddDay()
} }
s.realStartTime = nextSunTime.Carbon2Time() s.nextRunTime = nextSunTime.Carbon2Time()
} else { } 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()))
} }