Files
go-ha/schedule.go
2025-08-01 23:29:48 -05:00

298 lines
9.9 KiB
Go

// Package gomeassistant provides a Go library for creating Home Assistant automations
// and schedules. This file contains the scheduling system that allows you to create
// daily schedules with various conditions and callbacks.
package gomeassistant
import (
"fmt"
"log/slog"
"time"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
"github.com/dromara/carbon/v2"
)
// ScheduleCallback is a function type that gets called when a schedule triggers.
// It receives the service instance and current state as parameters.
type ScheduleCallback func(*Service, State)
// DailySchedule represents a recurring daily schedule with various conditions.
// It can be configured to run at specific times, sunrise/sunset, or based on
// entity states and date restrictions.
type DailySchedule struct {
// Hour of the day (0-23) when the schedule should run
hour int
// Minute of the hour (0-59) when the schedule should run
minute int
// Function to call when the schedule triggers
callback ScheduleCallback
// Next time this schedule should run
nextRunTime time.Time
// If true, schedule runs at sunrise instead of fixed time
isSunrise bool
// If true, schedule runs at sunset instead of fixed time
isSunset bool
// Offset from sunrise/sunset (e.g., "-30m", "+1h")
sunOffset types.DurationString
// Dates when this schedule should NOT run
exceptionDates []time.Time
// Dates when this schedule is ONLY allowed to run (if empty, runs on all dates)
allowlistDates []time.Time
// Entities that must be in specific states for this schedule to run
enabledEntities []internal.EnabledDisabledInfo
// Entities that must NOT be in specific states for this schedule to run
disabledEntities []internal.EnabledDisabledInfo
}
// Hash returns a unique string identifier for this schedule based on its
// time and callback function.
func (s DailySchedule) Hash() string {
return fmt.Sprint(s.hour, s.minute, s.callback)
}
// scheduleBuilder is used in the fluent API to build schedules step by step.
type scheduleBuilder struct {
schedule DailySchedule
}
// scheduleBuilderCall represents the state after setting the callback function.
type scheduleBuilderCall struct {
schedule DailySchedule
}
// scheduleBuilderEnd represents the final state where time and conditions are set.
type scheduleBuilderEnd struct {
schedule DailySchedule
}
// NewDailySchedule creates a new schedule builder with default values.
// Use the fluent API to configure the schedule:
//
// NewDailySchedule().Call(myFunction).At("15:30").Build()
func NewDailySchedule() scheduleBuilder {
return scheduleBuilder{
DailySchedule{
hour: 0,
minute: 0,
sunOffset: "0s",
},
}
}
// String returns a human-readable representation of the schedule.
func (s DailySchedule) String() string {
return fmt.Sprintf("Schedule{ call %q daily at %s }",
internal.GetFunctionName(s.callback),
stringHourMinute(s.hour, s.minute),
)
}
// stringHourMinute formats hour and minute as "HH:MM".
func stringHourMinute(hour, minute int) string {
return fmt.Sprintf("%02d:%02d", hour, minute)
}
// Call sets the callback function that will be executed when the schedule triggers.
// This is the first step in the fluent API chain.
func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
sb.schedule.callback = callback
return scheduleBuilderCall(sb)
}
// At sets the schedule to run at a specific time in 24-hour format.
// Examples: "15:30", "09:00", "23:45"
func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
t := internal.ParseTime(s)
sb.schedule.hour = t.Hour()
sb.schedule.minute = t.Minute()
return scheduleBuilderEnd(sb)
}
// Sunrise configures the schedule to run at sunrise with an optional offset.
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// Only the first offset, if provided, is considered.
// Examples:
// - Sunrise() - runs at sunrise
// - Sunrise("-30m") - runs 30 minutes before sunrise
// - Sunrise("+1h") - runs 1 hour after sunrise
func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunrise = true
if len(offset) > 0 {
sb.schedule.sunOffset = offset[0]
}
return scheduleBuilderEnd(sb)
}
// Sunset configures the schedule to run at sunset with an optional offset.
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// Only the first offset, if provided, is considered.
// Examples:
// - Sunset() - runs at sunset
// - Sunset("-30m") - runs 30 minutes before sunset
// - Sunset("+1h") - runs 1 hour after sunset
func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunset = true
if len(offset) > 0 {
sb.schedule.sunOffset = offset[0]
}
return scheduleBuilderEnd(sb)
}
// ExceptionDates adds dates when this schedule should NOT run.
// You can pass multiple dates: ExceptionDates(date1, date2, date3)
func (sb scheduleBuilderEnd) ExceptionDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.exceptionDates = append(tl, t)
return sb
}
// OnlyOnDates restricts the schedule to run ONLY on the specified dates.
// If no dates are specified, the schedule runs on all dates.
// You can pass multiple dates: OnlyOnDates(date1, date2, date3)
func (sb scheduleBuilderEnd) OnlyOnDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.allowlistDates = append(tl, t)
return sb
}
// EnabledWhen makes this schedule only run when the specified entity is in the given state.
// If there's a network error while checking the entity state, the schedule runs
// only if runOnNetworkError is true.
// Examples:
// - EnabledWhen("light.living_room", "on", true) - only run when light is on
// - EnabledWhen("sensor.motion", "detected", false) - only run when motion detected, fail on network error
func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
}
i := internal.EnabledDisabledInfo{
Entity: entityId,
State: state,
RunOnError: runOnNetworkError,
}
sb.schedule.enabledEntities = append(sb.schedule.enabledEntities, i)
return sb
}
// DisabledWhen prevents this schedule from running when the specified entity is in the given state.
// If there's a network error while checking the entity state, the schedule runs only if runOnNetworkError is true.
// Examples:
// - DisabledWhen("light.living_room", "off", true) - don't run when light is off
// - DisabledWhen("sensor.motion", "detected", false) - don't run when motion detected, fail on network error
func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
}
i := internal.EnabledDisabledInfo{
Entity: entityId,
State: state,
RunOnError: runOnNetworkError,
}
sb.schedule.disabledEntities = append(sb.schedule.disabledEntities, i)
return sb
}
// Build finalizes the schedule configuration and returns the DailySchedule.
// This is the final step in the fluent API chain.
func (sb scheduleBuilderEnd) Build() DailySchedule {
return sb.schedule
}
// runSchedules is the main goroutine that manages all schedules.
// It continuously processes schedules, running them when their time comes
// and requeuing them for the next day.
func runSchedules(a *App) {
if a.schedules.Len() == 0 {
return
}
for {
select {
case <-a.ctx.Done():
slog.Info("Schedules goroutine shutting down")
return
default:
}
sched := popSchedule(a)
// Run callback for all schedules that are overdue in case they overlap
for sched.nextRunTime.Before(time.Now()) {
sched.maybeRunCallback(a)
requeueSchedule(a, sched)
sched = popSchedule(a)
}
slog.Info("Next schedule", "start_time", sched.nextRunTime)
// Wait until the next schedule time or context cancellation
select {
case <-time.After(time.Until(sched.nextRunTime)):
// Time elapsed, continue
case <-a.ctx.Done():
slog.Info("Schedules goroutine shutting down")
return
}
sched.maybeRunCallback(a)
requeueSchedule(a, sched)
}
}
// maybeRunCallback checks all conditions and runs the callback if they're all met.
// Conditions checked:
// 1. Exception dates (schedule should not run on these dates)
// 2. Allowlist dates (schedule should only run on these dates)
// 3. Enabled entities (required entity states)
// 4. Disabled entities (forbidden entity states)
// The callback runs in a goroutine to avoid blocking the scheduler.
func (s DailySchedule) maybeRunCallback(a *App) {
if c := CheckExceptionDates(s.exceptionDates); c.fail {
return
}
if c := CheckAllowlistDates(s.allowlistDates); c.fail {
return
}
if c := CheckEnabledEntity(a.state, s.enabledEntities); c.fail {
return
}
if c := CheckDisabledEntity(a.state, s.disabledEntities); c.fail {
return
}
go s.callback(a.service, a.state)
}
// popSchedule removes and returns the next schedule from the priority queue.
func popSchedule(a *App) DailySchedule {
_sched, _ := a.schedules.Get(1)
return _sched[0].(Item).Value.(DailySchedule)
}
// requeueSchedule calculates the next run time for a schedule and adds it back to the queue.
// For sunrise/sunset schedules, it calculates the next sunrise/sunset time.
// For fixed-time schedules, it adds one day to the current run time.
func requeueSchedule(a *App, s DailySchedule) {
if s.isSunrise || s.isSunset {
var nextSunTime *carbon.Carbon
// "0s" is the default value for no offset
if s.sunOffset != "0s" {
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset)
} else {
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise)
}
s.nextRunTime = nextSunTime.StdTime()
} else {
s.nextRunTime = carbon.CreateFromStdTime(s.nextRunTime).AddDay().StdTime()
}
a.schedules.Put(Item{
Value: s,
Priority: float64(s.nextRunTime.Unix()),
})
}