diff --git a/internal/misc.go b/internal/misc.go index f2450c3..1a6a3f8 100644 --- a/internal/misc.go +++ b/internal/misc.go @@ -47,3 +47,7 @@ func GetEquivalentWebsocketScheme(scheme string) (string, error) { return "", fmt.Errorf("unexpected scheme: %s", scheme) } } + +func Ptr[T any](v T) *T { + return &v +} diff --git a/internal/scheduling/builder.go b/internal/scheduling/builder.go new file mode 100644 index 0000000..34e9eb1 --- /dev/null +++ b/internal/scheduling/builder.go @@ -0,0 +1,114 @@ +package scheduling + +import ( + "fmt" + "time" + + "github.com/Xevion/go-ha/types" +) + +type DailyScheduleBuilder struct { + errors []error + hashes map[uint64]bool + triggers []Trigger +} + +func NewSchedule() *DailyScheduleBuilder { + return &DailyScheduleBuilder{ + hashes: make(map[uint64]bool), + } +} + +// tryAddTrigger adds a trigger to the builder if it is not already present. +// If the trigger is already present, an error will be added to the builder's errors. +// It will return the builder for chaining. +func (b *DailyScheduleBuilder) tryAddTrigger(trigger Trigger) *DailyScheduleBuilder { + hash := trigger.Hash() + if _, ok := b.hashes[hash]; ok { + b.errors = append(b.errors, fmt.Errorf("duplicate trigger: %v", trigger)) + return b + } + + b.triggers = append(b.triggers, trigger) + b.hashes[hash] = true + + return b +} + +func (b *DailyScheduleBuilder) onSun(sunset bool, offset ...types.DurationString) *DailyScheduleBuilder { + if len(offset) == 0 { + b.errors = append(b.errors, fmt.Errorf("no offset provided for sun")) + return b + } + + offsetDuration, err := time.ParseDuration(string(offset[0])) + if err != nil { + b.errors = append(b.errors, err) + return b + } + + return b.tryAddTrigger(&SunTrigger{ + sunset: sunset, + offset: &offsetDuration, + }) +} + +// OnSunrise adds a trigger for sunrise with an optional offset. +// Only the first offset is considered. +// You can call this multiple times to add multiple triggers for sunrise with different offsets. +func (b *DailyScheduleBuilder) OnSunrise(offset ...types.DurationString) *DailyScheduleBuilder { + return b.onSun(false, offset...) +} + +// OnSunset adds a trigger for sunset with an optional offset. +// Only the first offset is considered. +func (b *DailyScheduleBuilder) OnSunset(offset ...types.DurationString) *DailyScheduleBuilder { + return b.onSun(true, offset...) +} + +// OnFixedTime adds a trigger for a fixed time each day. +// The time is in the local timezone. +// This will error if the integer values are not in the range 0-23 for the hour and 0-59 for the minute. +func (b *DailyScheduleBuilder) OnFixedTime(hour, minute int) *DailyScheduleBuilder { + errored := false + if hour < 0 || hour > 23 { + b.errors = append(b.errors, fmt.Errorf("hour must be between 0 and 23")) + errored = true + } + + if minute < 0 || minute > 59 { + b.errors = append(b.errors, fmt.Errorf("minute must be between 0 and 59")) + errored = true + } + + if errored { + return b + } + + return b.tryAddTrigger(&FixedTimeTrigger{ + Hour: hour, + Minute: minute, + }) +} + +// Build returns a Trigger that will trigger at the configured times. +// It will return an error if any errors occurred during configuration. +func (b *DailyScheduleBuilder) Build() (Trigger, error) { + // If there are no triggers, add an error. + if len(b.triggers) == 0 { + b.errors = append(b.errors, fmt.Errorf("no triggers provided")) + } + + // If there are errors, return an error. + if len(b.errors) > 0 { + return nil, fmt.Errorf("errors occurred: %v", b.errors) + } + + // If there is only one trigger, return it. + if len(b.triggers) == 1 { + return b.triggers[0], nil + } + + // Otherwise, return a composite schedule that combines all the triggers. + return &CompositeDailySchedule{triggers: b.triggers}, nil +} diff --git a/internal/scheduling/builder_test.go b/internal/scheduling/builder_test.go new file mode 100644 index 0000000..d41cf31 --- /dev/null +++ b/internal/scheduling/builder_test.go @@ -0,0 +1,353 @@ +package scheduling + +import ( + "fmt" + "testing" + "time" + + "github.com/Xevion/go-ha/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSchedule(t *testing.T) { + builder := NewSchedule() + assert.NotNil(t, builder) + assert.Empty(t, builder.errors) + assert.Empty(t, builder.triggers) + assert.NotNil(t, builder.hashes) +} + +func TestDailyScheduleBuilder_OnFixedTime(t *testing.T) { + tests := []struct { + name string + hour int + minute int + expectError bool + }{ + { + name: "valid time", + hour: 12, + minute: 30, + expectError: false, + }, + { + name: "midnight", + hour: 0, + minute: 0, + expectError: false, + }, + { + name: "invalid hour negative", + hour: -1, + minute: 30, + expectError: true, + }, + { + name: "invalid hour too high", + hour: 24, + minute: 30, + expectError: true, + }, + { + name: "invalid minute negative", + hour: 12, + minute: -1, + expectError: true, + }, + { + name: "invalid minute too high", + hour: 12, + minute: 60, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewSchedule() + result := builder.OnFixedTime(tt.hour, tt.minute) + + assert.Equal(t, builder, result) // Should return self for chaining + + if tt.expectError { + assert.Len(t, builder.errors, 1) + } else { + assert.Empty(t, builder.errors) + assert.Len(t, builder.triggers, 1) + } + }) + } +} + +func TestDailyScheduleBuilder_OnSunrise(t *testing.T) { + tests := []struct { + name string + offset []types.DurationString + expectError bool + }{ + { + name: "with offset", + offset: []types.DurationString{"30m"}, + expectError: false, + }, + { + name: "with negative offset", + offset: []types.DurationString{"-1h"}, + expectError: false, + }, + { + name: "no offset", + offset: []types.DurationString{}, + expectError: true, + }, + { + name: "invalid duration", + offset: []types.DurationString{"invalid"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewSchedule() + result := builder.OnSunrise(tt.offset...) + + assert.Equal(t, builder, result) // Should return self for chaining + + if tt.expectError { + assert.Len(t, builder.errors, 1) + } else { + assert.Empty(t, builder.errors) + assert.Len(t, builder.triggers, 1) + } + }) + } +} + +func TestDailyScheduleBuilder_OnSunset(t *testing.T) { + tests := []struct { + name string + offset []types.DurationString + expectError bool + }{ + { + name: "with offset", + offset: []types.DurationString{"1h"}, + expectError: false, + }, + { + name: "with negative offset", + offset: []types.DurationString{"-30m"}, + expectError: false, + }, + { + name: "no offset", + offset: []types.DurationString{}, + expectError: true, + }, + { + name: "invalid duration", + offset: []types.DurationString{"invalid"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewSchedule() + result := builder.OnSunset(tt.offset...) + + assert.Equal(t, builder, result) // Should return self for chaining + + if tt.expectError { + assert.Len(t, builder.errors, 1) + } else { + assert.Empty(t, builder.errors) + assert.Len(t, builder.triggers, 1) + } + }) + } +} + +func TestDailyScheduleBuilder_DuplicateTriggers(t *testing.T) { + builder := NewSchedule() + + // Add the same fixed time trigger twice + builder.OnFixedTime(12, 30) + builder.OnFixedTime(12, 30) + + assert.Len(t, builder.errors, 1) + assert.Len(t, builder.triggers, 1) // Only one should be added + assert.Contains(t, builder.errors[0].Error(), "duplicate trigger") +} + +func TestDailyScheduleBuilder_Build_Success(t *testing.T) { + tests := []struct { + name string + setupBuilder func(*DailyScheduleBuilder) + expectedType string + expectedCount int + }{ + { + name: "single fixed time trigger", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnFixedTime(12, 30) + }, + expectedType: "*scheduling.FixedTimeTrigger", + expectedCount: 1, + }, + { + name: "single sunrise trigger", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnSunrise("30m") + }, + expectedType: "*scheduling.SunTrigger", + expectedCount: 1, + }, + { + name: "multiple triggers", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnFixedTime(8, 0) + b.OnFixedTime(12, 0) + b.OnSunrise("1h") + }, + expectedType: "*scheduling.CompositeDailySchedule", + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewSchedule() + tt.setupBuilder(builder) + + trigger, err := builder.Build() + + require.NoError(t, err) + require.NotNil(t, trigger) + assert.Equal(t, tt.expectedType, fmt.Sprintf("%T", trigger)) + + // Test that the trigger works + now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local) + result := trigger.NextTime(now) + assert.NotNil(t, result) + }) + } +} + +func TestDailyScheduleBuilder_Build_Errors(t *testing.T) { + tests := []struct { + name string + setupBuilder func(*DailyScheduleBuilder) + expectError bool + }{ + { + name: "no triggers", + setupBuilder: func(b *DailyScheduleBuilder) { + // Don't add any triggers + }, + expectError: true, + }, + { + name: "invalid hour", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnFixedTime(25, 0) // Invalid hour + }, + expectError: true, + }, + { + name: "invalid minute", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnFixedTime(12, 60) // Invalid minute + }, + expectError: true, + }, + { + name: "no offset for sun trigger", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnSunrise() // No offset + }, + expectError: true, + }, + { + name: "invalid duration", + setupBuilder: func(b *DailyScheduleBuilder) { + b.OnSunset("invalid") // Invalid duration + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder := NewSchedule() + tt.setupBuilder(builder) + + trigger, err := builder.Build() + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, trigger) + } else { + assert.NoError(t, err) + assert.NotNil(t, trigger) + } + }) + } +} + +func TestDailyScheduleBuilder_Chaining(t *testing.T) { + builder := NewSchedule() + + // Test method chaining + result := builder. + OnFixedTime(8, 0). + OnFixedTime(12, 0). + OnSunrise("30m") + + assert.Equal(t, builder, result) + assert.Len(t, builder.triggers, 3) + assert.Empty(t, builder.errors) +} + +func TestDailyScheduleBuilder_NextTime_Integration(t *testing.T) { + builder := NewSchedule() + builder.OnFixedTime(8, 0). + OnFixedTime(12, 0). + OnFixedTime(18, 0) + + trigger, err := builder.Build() + require.NoError(t, err) + + // Test at different times + tests := []struct { + name string + now time.Time + expected time.Time + }{ + { + name: "before all triggers", + now: time.Date(2025, 8, 2, 6, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 2, 8, 0, 0, 0, time.Local), + }, + { + name: "between triggers", + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local), + }, + { + name: "after all triggers", + now: time.Date(2025, 8, 2, 20, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := trigger.NextTime(tt.now) + require.NotNil(t, result) + assert.Equal(t, tt.expected, *result) + }) + } +} diff --git a/internal/scheduling/daily.go b/internal/scheduling/daily.go new file mode 100644 index 0000000..82034c2 --- /dev/null +++ b/internal/scheduling/daily.go @@ -0,0 +1,110 @@ +package scheduling + +import ( + "fmt" + "hash/fnv" + "time" + + "github.com/Xevion/go-ha/internal" + "github.com/dromara/carbon/v2" + "github.com/nathan-osman/go-sunrise" +) + +type Trigger interface { + // NextTime calculates the next occurrence of this trigger after the given time + NextTime(now time.Time) *time.Time + Hash() uint64 +} + +// FixedTimeTrigger represents a trigger at a specific hour and minute each day +type FixedTimeTrigger struct { + Hour int // 0-23 + Minute int // 0-59 +} + +// SunTrigger represents a trigger based on sunrise or sunset with optional offset +type SunTrigger struct { + latitude float64 // latitude of the location + longitude float64 // longitude of the location + sunset bool // true for sunset, false for sunrise + offset *time.Duration // offset from sun event (can be negative) +} + +func (t *FixedTimeTrigger) NextTime(now time.Time) *time.Time { + next := carbon.NewCarbon(now).SetHour(t.Hour).SetMinute(t.Minute) + + // If the calculated time is before or equal to now, advance to the next day + if !next.StdTime().After(now) { + next = next.AddDay() + } + + return internal.Ptr(next.StdTime().Local()) +} + +// Hash returns a stable hash value for the FixedTimeTrigger +func (t *FixedTimeTrigger) Hash() uint64 { + h := fnv.New64() + fmt.Fprintf(h, "%d:%d", t.Hour, t.Minute) + return h.Sum64() +} + +// NextTime returns the next time the sun will rise or set. If an offset is provided, it will be added to the calculated time. +func (t *SunTrigger) NextTime(now time.Time) *time.Time { + var sun time.Time + + if t.sunset { + _, sun = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day()) + } else { + sun, _ = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day()) + } + + // In the case that the sun does not rise or set on the given day, return nil + if sun.IsZero() { + return nil + } + + sun = sun.Local() // Convert to local time + if t.offset != nil && *t.offset != 0 { + sun = sun.Add(*t.offset) // Add the offset if provided and not zero + } + + return &sun +} + +// Hash returns a stable hash value for the SunTrigger +func (t *SunTrigger) Hash() uint64 { + h := fnv.New64() + fmt.Fprintf(h, "%f:%f:%t", t.latitude, t.longitude, t.sunset) + if t.offset != nil { + fmt.Fprintf(h, ":%d", t.offset.Nanoseconds()) + } + return h.Sum64() +} + +// CompositeDailySchedule combines multiple triggers into a single daily schedule. +type CompositeDailySchedule struct { + triggers []Trigger +} + +// NextTime returns the next time the first viable trigger will run. +func (c *CompositeDailySchedule) NextTime(now time.Time) *time.Time { + best := c.triggers[0].NextTime(now) + + for _, trigger := range c.triggers[1:] { + potential := trigger.NextTime(now) + if potential != nil && (best == nil || potential.Before(*best)) { + best = potential + } + } + + return best +} + +// Hash returns a stable hash value for the CompositeDailySchedule +func (c *CompositeDailySchedule) Hash() uint64 { + h := fnv.New64() + for _, trigger := range c.triggers { + fmt.Fprintf(h, "%d", trigger.Hash()) + } + return h.Sum64() +} diff --git a/internal/scheduling/daily_test.go b/internal/scheduling/daily_test.go new file mode 100644 index 0000000..e885a55 --- /dev/null +++ b/internal/scheduling/daily_test.go @@ -0,0 +1,300 @@ +package scheduling + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFixedTimeTrigger_NextTime(t *testing.T) { + tests := []struct { + name string + hour int + minute int + now time.Time + expected time.Time + }{ + { + name: "same day trigger", + hour: 14, + minute: 30, + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 2, 14, 30, 0, 0, time.Local), + }, + { + name: "next day trigger", + hour: 8, + minute: 0, + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local), + }, + { + name: "exact time", + hour: 10, + minute: 0, + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: time.Date(2025, 8, 3, 10, 0, 0, 0, time.Local), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trigger := &FixedTimeTrigger{ + Hour: tt.hour, + Minute: tt.minute, + } + + result := trigger.NextTime(tt.now) + require.NotNil(t, result) + assert.Equal(t, tt.expected, *result) + }) + } +} + +func TestFixedTimeTrigger_Hash(t *testing.T) { + tests := []struct { + name string + hour int + minute int + expected uint64 + }{ + { + name: "basic time", + hour: 12, + minute: 30, + expected: 0, // We'll check it's not zero + }, + { + name: "midnight", + hour: 0, + minute: 0, + expected: 0, // We'll check it's not zero + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trigger := &FixedTimeTrigger{ + Hour: tt.hour, + Minute: tt.minute, + } + + hash := trigger.Hash() + assert.NotZero(t, hash) + assert.IsType(t, uint64(0), hash) + }) + } + + // Test that different times produce different hashes + trigger1 := &FixedTimeTrigger{Hour: 12, Minute: 30} + trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 31} + trigger3 := &FixedTimeTrigger{Hour: 13, Minute: 30} + + hash1 := trigger1.Hash() + hash2 := trigger2.Hash() + hash3 := trigger3.Hash() + + assert.NotEqual(t, hash1, hash2) + assert.NotEqual(t, hash1, hash3) + assert.NotEqual(t, hash2, hash3) + + // Test that same times produce same hashes + trigger4 := &FixedTimeTrigger{Hour: 12, Minute: 30} + hash4 := trigger4.Hash() + assert.Equal(t, hash1, hash4) +} + +func TestSunTrigger_NextTime(t *testing.T) { + // Test with a known location (New York City) + lat, lon := 40.7128, -74.0060 + + tests := []struct { + name string + sunset bool + offset *time.Duration + now time.Time + expected bool // whether we expect a result + }{ + { + name: "sunrise without offset", + sunset: false, + offset: nil, + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: true, + }, + { + name: "sunset without offset", + sunset: true, + offset: nil, + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: true, + }, + { + name: "sunrise with positive offset", + sunset: false, + offset: func() *time.Duration { d := 30 * time.Minute; return &d }(), + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: true, + }, + { + name: "sunset with negative offset", + sunset: true, + offset: func() *time.Duration { d := -1 * time.Hour; return &d }(), + now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trigger := &SunTrigger{ + latitude: lat, + longitude: lon, + sunset: tt.sunset, + offset: tt.offset, + } + + result := trigger.NextTime(tt.now) + if tt.expected { + require.NotNil(t, result) + assert.False(t, result.IsZero()) + } else { + // For polar regions or extreme dates, sun might not rise/set + // This is acceptable behavior + } + }) + } +} + +func TestSunTrigger_Hash(t *testing.T) { + lat1, lon1 := 40.7128, -74.0060 + lat2, lon2 := 51.5074, -0.1278 + + tests := []struct { + name string + lat float64 + lon float64 + sunset bool + offset *time.Duration + }{ + { + name: "sunrise without offset", + lat: lat1, + lon: lon1, + sunset: false, + offset: nil, + }, + { + name: "sunset with offset", + lat: lat1, + lon: lon1, + sunset: true, + offset: func() *time.Duration { d := 30 * time.Minute; return &d }(), + }, + { + name: "different location", + lat: lat2, + lon: lon2, + sunset: false, + offset: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trigger := &SunTrigger{ + latitude: tt.lat, + longitude: tt.lon, + sunset: tt.sunset, + offset: tt.offset, + } + + hash := trigger.Hash() + assert.NotZero(t, hash) + assert.IsType(t, uint64(0), hash) + }) + } + + // Test that different configurations produce different hashes + trigger1 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil} + trigger2 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: true, offset: nil} + trigger3 := &SunTrigger{latitude: lat2, longitude: lon2, sunset: false, offset: nil} + + hash1 := trigger1.Hash() + hash2 := trigger2.Hash() + hash3 := trigger3.Hash() + + assert.NotEqual(t, hash1, hash2) + assert.NotEqual(t, hash1, hash3) + assert.NotEqual(t, hash2, hash3) + + // Test that same configurations produce same hashes + trigger4 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil} + hash4 := trigger4.Hash() + assert.Equal(t, hash1, hash4) +} + +func TestCompositeDailySchedule_NextTime(t *testing.T) { + trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0} + trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0} + trigger3 := &FixedTimeTrigger{Hour: 18, Minute: 0} + + composite := &CompositeDailySchedule{ + triggers: []Trigger{trigger1, trigger2, trigger3}, + } + + now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local) + result := composite.NextTime(now) + + require.NotNil(t, result) + // Should return the earliest trigger after now (12:00) + expected := time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local) + assert.Equal(t, expected, *result) +} + +func TestCompositeDailySchedule_Hash(t *testing.T) { + trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0} + trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0} + + composite1 := &CompositeDailySchedule{ + triggers: []Trigger{trigger1, trigger2}, + } + + composite2 := &CompositeDailySchedule{ + triggers: []Trigger{trigger2, trigger1}, // Different order + } + + composite3 := &CompositeDailySchedule{ + triggers: []Trigger{trigger1}, // Different number of triggers + } + + hash1 := composite1.Hash() + hash2 := composite2.Hash() + hash3 := composite3.Hash() + + assert.NotZero(t, hash1) + assert.NotZero(t, hash2) + assert.NotZero(t, hash3) + assert.IsType(t, uint64(0), hash1) + + // Different orders should produce different hashes + assert.NotEqual(t, hash1, hash2) + assert.NotEqual(t, hash1, hash3) + assert.NotEqual(t, hash2, hash3) + + // Same configuration should produce same hash + composite4 := &CompositeDailySchedule{ + triggers: []Trigger{trigger1, trigger2}, + } + hash4 := composite4.Hash() + assert.Equal(t, hash1, hash4) +} + +func TestTriggerInterface(t *testing.T) { + // Test that all trigger types implement the Trigger interface + var _ Trigger = &FixedTimeTrigger{} + var _ Trigger = &SunTrigger{} + var _ Trigger = &CompositeDailySchedule{} +}