Skip to content

Commit 44b0e3b

Browse files
authored
Merge pull request #189 from calvinmclean/feature/restart-light
Enable server to set Controller's LightState when it detects reconnect
2 parents cda7562 + d30d325 commit 44b0e3b

File tree

6 files changed

+278
-26
lines changed

6 files changed

+278
-26
lines changed

garden-app/pkg/light_schedule.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,44 @@ func (ls *LightSchedule) Patch(newLightSchedule *LightSchedule) {
8888
ls.AdhocOnTime = nil
8989
}
9090
}
91+
92+
// ExpectedStateAtTime returns the expected state for a LightSchedule at a specific time
93+
func (ls LightSchedule) ExpectedStateAtTime(now time.Time) LightState {
94+
_, state := ls.NextChange(now)
95+
return state ^ 1
96+
}
97+
98+
// NextChange determines what the next LightState change will be and at what time. For example, consider a LightSchedule
99+
// that turns on at 8PM for 12 hours. At 7PM, this will return (8PM, ON). At 9PM, it returns (8AM, OFF).
100+
func (ls LightSchedule) NextChange(now time.Time) (time.Time, LightState) {
101+
// LightSchedules operate on a 24-hour interval, so we have a time for today's schedule
102+
todayOnTime := ls.StartTime.OnDate(now)
103+
todayOffTime := todayOnTime.Add(ls.Duration.Duration)
104+
105+
// and one for yesterday's which could still be active
106+
yesterdayOnTime := todayOnTime.AddDate(0, 0, -1)
107+
yesterdayOffTime := todayOffTime.AddDate(0, 0, -1)
108+
109+
withinTodaysDuration := todayOnTime.Before(now) && todayOffTime.After(now)
110+
if withinTodaysDuration {
111+
return todayOffTime, LightStateOff
112+
}
113+
114+
withinYesterdayDuration := yesterdayOnTime.Before(now) && yesterdayOffTime.After(now)
115+
if withinYesterdayDuration {
116+
return yesterdayOffTime, LightStateOff
117+
}
118+
119+
alreadyOnAndOffToday := todayOffTime.Before(now)
120+
if alreadyOnAndOffToday {
121+
// turns on again tomorrow
122+
return todayOnTime.AddDate(0, 0, 1), LightStateOn
123+
}
124+
125+
notOnYetToday := todayOnTime.After(now)
126+
if notOnYetToday {
127+
return todayOnTime, LightStateOn
128+
}
129+
130+
return time.Time{}, LightStateToggle
131+
}

garden-app/pkg/light_schedule_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package pkg
33
import (
44
"encoding/json"
55
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
69
)
710

811
func TestLightStateString(t *testing.T) {
@@ -138,3 +141,117 @@ func TestLightStateMarshal(t *testing.T) {
138141
}
139142
})
140143
}
144+
145+
func TestNextChange(t *testing.T) {
146+
tests := []struct {
147+
name string
148+
ls LightSchedule
149+
currentTime time.Time
150+
expectedTime time.Time
151+
expectedState LightState
152+
}{
153+
{
154+
name: "OnInOneHour",
155+
ls: LightSchedule{
156+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 13, 0, 0, 0, time.UTC)},
157+
Duration: &Duration{Duration: 12 * time.Hour},
158+
},
159+
currentTime: time.Date(2025, time.November, 8, 12, 0, 0, 0, time.UTC),
160+
expectedTime: time.Date(2025, time.November, 8, 13, 0, 0, 0, time.UTC),
161+
expectedState: LightStateOn,
162+
},
163+
{
164+
name: "OffInElevenHours",
165+
ls: LightSchedule{
166+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 13, 0, 0, 0, time.UTC)},
167+
Duration: &Duration{Duration: 12 * time.Hour},
168+
},
169+
currentTime: time.Date(2025, time.November, 8, 14, 0, 0, 0, time.UTC),
170+
expectedTime: time.Date(2025, time.November, 9, 1, 0, 0, 0, time.UTC),
171+
expectedState: LightStateOff,
172+
},
173+
{
174+
name: "TurnedOnYesterdayAndTurnsOffLater",
175+
ls: LightSchedule{
176+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 20, 0, 0, 0, time.UTC)},
177+
Duration: &Duration{Duration: 12 * time.Hour},
178+
},
179+
currentTime: time.Date(2025, time.November, 8, 6, 0, 0, 0, time.UTC),
180+
expectedTime: time.Date(2025, time.November, 8, 8, 0, 0, 0, time.UTC),
181+
expectedState: LightStateOff,
182+
},
183+
{
184+
// Light turns on at 7AM and off at 7PM. It is currently 10PM, so it will turn on tomorrow morning
185+
name: "TurnsOnAgainTomorrow",
186+
ls: LightSchedule{
187+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 0o7, 0, 0, 0, time.UTC)},
188+
Duration: &Duration{Duration: 12 * time.Hour},
189+
},
190+
currentTime: time.Date(2023, time.November, 8, 22, 0, 0, 0, time.UTC),
191+
expectedTime: time.Date(2023, time.November, 9, 0o7, 0, 0, 0, time.UTC),
192+
expectedState: LightStateOn,
193+
},
194+
}
195+
196+
for _, tt := range tests {
197+
t.Run(tt.name, func(t *testing.T) {
198+
nextTime, nextState := tt.ls.NextChange(tt.currentTime)
199+
assert.Equal(t, tt.expectedTime, nextTime)
200+
assert.Equal(t, tt.expectedState, nextState)
201+
})
202+
}
203+
}
204+
205+
func TestExpectedStateAtTime(t *testing.T) {
206+
tests := []struct {
207+
name string
208+
ls LightSchedule
209+
currentTime time.Time
210+
expectedState LightState
211+
}{
212+
{
213+
name: "OnInOneHour_CurrentlyOff",
214+
ls: LightSchedule{
215+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 13, 0, 0, 0, time.UTC)},
216+
Duration: &Duration{Duration: 12 * time.Hour},
217+
},
218+
currentTime: time.Date(2025, time.November, 8, 12, 0, 0, 0, time.UTC),
219+
expectedState: LightStateOff,
220+
},
221+
{
222+
name: "OffInElevenHours_CurrentlyOn",
223+
ls: LightSchedule{
224+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 13, 0, 0, 0, time.UTC)},
225+
Duration: &Duration{Duration: 12 * time.Hour},
226+
},
227+
currentTime: time.Date(2025, time.November, 8, 14, 0, 0, 0, time.UTC),
228+
expectedState: LightStateOn,
229+
},
230+
{
231+
name: "TurnedOnYesterdayAndTurnsOffLater_CurrentlyOn",
232+
ls: LightSchedule{
233+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 20, 0, 0, 0, time.UTC)},
234+
Duration: &Duration{Duration: 12 * time.Hour},
235+
},
236+
currentTime: time.Date(2025, time.November, 8, 6, 0, 0, 0, time.UTC),
237+
expectedState: LightStateOn,
238+
},
239+
{
240+
// Light runs from 5PM to 5AM and it is currently 8AM
241+
name: "CurrentlyOff",
242+
ls: LightSchedule{
243+
StartTime: &StartTime{Time: time.Date(0, 0, 0, 17, 0, 0, 0, time.UTC)},
244+
Duration: &Duration{Duration: 12 * time.Hour},
245+
},
246+
currentTime: time.Date(2025, time.November, 9, 8, 0, 0, 0, time.UTC),
247+
expectedState: LightStateOff,
248+
},
249+
}
250+
251+
for _, tt := range tests {
252+
t.Run(tt.name, func(t *testing.T) {
253+
currentState := tt.ls.ExpectedStateAtTime(tt.currentTime)
254+
assert.Equal(t, tt.expectedState, currentState)
255+
})
256+
}
257+
}

garden-app/pkg/start_time.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ func (st *StartTime) String() string {
3939
return st.Time.Format(startTimeFormat)
4040
}
4141

42+
// OnDate takes the StartTime hour/minute/second and applies to the date on the input
43+
func (st StartTime) OnDate(date time.Time) time.Time {
44+
date = date.In(st.Time.Location())
45+
return time.Date(
46+
date.Year(),
47+
date.Month(),
48+
date.Day(),
49+
st.Time.Hour(),
50+
st.Time.Minute(),
51+
st.Time.Second(),
52+
0,
53+
st.Time.Location(),
54+
)
55+
}
56+
4257
// Validate is used after parsing from HTML form so the time can be parsed
4358
func (st *StartTime) Validate() error {
4459
if !st.Time.IsZero() {

garden-app/worker/scheduler.go

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ func (w *Worker) ScheduleWaterAction(waterSchedule *pkg.WaterSchedule) error {
4242
logger := w.contextLogger(nil, nil, waterSchedule)
4343
logger.Info("creating scheduled Job for WaterSchedule")
4444

45-
startTime := waterSchedule.StartTime.Time.UTC()
45+
startDate := clock.Now()
46+
if waterSchedule.StartDate != nil {
47+
startDate = *waterSchedule.StartDate
48+
}
4649

4750
// Schedule the WaterAction execution
4851
scheduleJobsGauge.WithLabelValues(waterScheduleLabels(waterSchedule)...).Inc()
4952
_, err := waterSchedule.Interval.SchedulerFunc(w.scheduler).
50-
StartAt(timeAtDate(waterSchedule.StartDate, startTime)).
53+
StartAt(waterSchedule.StartTime.OnDate(startDate).UTC()).
5154
Tag("water_schedule").
5255
Tag(waterSchedule.ID.String()).
5356
Do(func(jobLogger *slog.Logger) {
@@ -187,10 +190,8 @@ func (w *Worker) ScheduleLightActions(g *pkg.Garden) error {
187190
logger := w.contextLogger(g, nil, nil)
188191
logger.Info("creating scheduled Jobs for lighting Garden", "light_schedule", *g.LightSchedule)
189192

190-
lightTime := g.LightSchedule.StartTime.Time.UTC()
191-
192193
now := clock.Now()
193-
onStartDate := timeAtDate(&now, lightTime)
194+
onStartDate := g.LightSchedule.StartTime.OnDate(now).UTC()
194195
offStartDate := onStartDate.Add(g.LightSchedule.Duration.Duration)
195196

196197
// Schedule the LightAction execution for ON and OFF
@@ -504,21 +505,3 @@ func (w *Worker) executeLightActionInScheduledJob(g *pkg.Garden, input *action.L
504505

505506
w.sendLightActionNotification(g, input.State, actionLogger)
506507
}
507-
508-
func timeAtDate(date *time.Time, startTime time.Time) time.Time {
509-
actualDate := clock.Now()
510-
if date != nil {
511-
actualDate = *date
512-
}
513-
actualDate = actualDate.In(startTime.Location())
514-
return time.Date(
515-
actualDate.Year(),
516-
actualDate.Month(),
517-
actualDate.Day(),
518-
startTime.Hour(),
519-
startTime.Minute(),
520-
startTime.Second(),
521-
0,
522-
startTime.Location(),
523-
)
524-
}

garden-app/worker/startup_notification_handler.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package worker
22

33
import (
4+
"errors"
45
"fmt"
56
"strings"
67

8+
"github.com/calvinmclean/automated-garden/garden-app/clock"
79
"github.com/calvinmclean/automated-garden/garden-app/pkg"
10+
"github.com/calvinmclean/automated-garden/garden-app/pkg/action"
811
mqtt "github.com/eclipse/paho.mqtt.golang"
912
)
1013

@@ -31,14 +34,45 @@ func (w *Worker) getGardenAndSendStartupMessage(topic string, payload string) er
3134
logger = logger.With("garden_id", garden.GetID())
3235
logger.Info("found garden with topic-prefix")
3336

37+
err = w.setExpectedLightState(garden)
38+
if err != nil {
39+
logger.Warn("unable to set expected LightState", "error", err.Error())
40+
msg += fmt.Sprintf(" Error setting LightState: %v", err)
41+
}
42+
3443
return w.sendGardenStartupMessage(garden, topic, msg)
3544
}
3645

46+
// setExpectedLightState is used when a GardenController connects/starts up. It sets the current
47+
// expected light state in case the last toggle was missed during downtime or turned off after crashing
48+
func (w *Worker) setExpectedLightState(garden *pkg.Garden) error {
49+
if garden == nil {
50+
return errors.New("nil Garden")
51+
}
52+
53+
if garden.LightSchedule == nil {
54+
return nil
55+
}
56+
57+
state := garden.LightSchedule.ExpectedStateAtTime(clock.Now())
58+
err := w.ExecuteLightAction(garden, &action.LightAction{
59+
State: state,
60+
})
61+
if err != nil {
62+
return fmt.Errorf("error executing LigthAction: %w", err)
63+
}
64+
65+
return nil
66+
}
67+
3768
func (w *Worker) sendGardenStartupMessage(garden *pkg.Garden, topic string, msg string) error {
38-
logger := w.logger.With("topic", topic)
69+
if garden == nil {
70+
return errors.New("nil Garden")
71+
}
72+
logger := w.logger.With("garden_id", garden.GetID(), "topic", topic)
3973

4074
if !garden.GetNotificationSettings().ControllerStartup {
41-
logger.Warn("garden does not have controller_startup notification enabled", "garden_id", garden.GetID())
75+
logger.Warn("garden does not have controller_startup notification enabled")
4276
return nil
4377
}
4478

garden-app/worker/startup_notification_handler_test.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,76 @@ package worker
22

33
import (
44
"bytes"
5+
"context"
6+
"fmt"
57
"log/slog"
68
"strings"
79
"testing"
10+
"time"
811

12+
"github.com/calvinmclean/automated-garden/garden-app/clock"
913
"github.com/calvinmclean/automated-garden/garden-app/pkg"
14+
"github.com/calvinmclean/automated-garden/garden-app/pkg/mqtt"
15+
"github.com/calvinmclean/automated-garden/garden-app/pkg/storage"
16+
"github.com/calvinmclean/babyapi"
1017
"github.com/stretchr/testify/require"
1118
)
1219

20+
func TestGetGardenAndSendStartupMessage(t *testing.T) {
21+
// When a GardenController reboots, the light probably turned off. If the LightSchedule shows it should be on,
22+
// turn it on
23+
c := clock.MockTime()
24+
defer clock.Reset()
25+
now := c.Now()
26+
27+
storageClient, err := storage.NewClient(storage.Config{
28+
Driver: "hashmap",
29+
})
30+
require.NoError(t, err)
31+
32+
garden := &pkg.Garden{
33+
ID: babyapi.NewID(),
34+
TopicPrefix: "garden",
35+
Name: "garden",
36+
// This light scheduled turned on 3 hours ago and should still be on due to the 12 hour duration
37+
LightSchedule: &pkg.LightSchedule{
38+
Duration: &pkg.Duration{Duration: 12 * time.Hour},
39+
StartTime: &pkg.StartTime{
40+
Time: now.Add(-3 * time.Hour),
41+
},
42+
},
43+
}
44+
err = storageClient.Gardens.Set(context.Background(), garden)
45+
require.NoError(t, err)
46+
47+
mqttClient := new(mqtt.MockClient)
48+
w := NewWorker(storageClient, nil, mqttClient, slog.Default())
49+
50+
t.Run("LightTurnsOn", func(t *testing.T) {
51+
mqttClient.On("Publish", "garden/command/light", []byte(`{"state":"ON","for_duration":null}`)).Return(nil)
52+
err = w.getGardenAndSendStartupMessage("garden/data/logs", "logs message=\"garden-controller setup complete\"")
53+
require.NoError(t, err)
54+
mqttClient.AssertExpectations(t)
55+
})
56+
57+
t.Run("LightTurnsOff", func(t *testing.T) {
58+
c.Add(12 * time.Hour)
59+
fmt.Println("LightTime", garden.LightSchedule.StartTime.Time)
60+
fmt.Println("Now", clock.Now())
61+
fmt.Println(garden.LightSchedule.NextChange(c.Now()))
62+
mqttClient.On("Publish", "garden/command/light", []byte(`{"state":"OFF","for_duration":null}`)).Return(nil)
63+
err = w.getGardenAndSendStartupMessage("garden/data/logs", "logs message=\"garden-controller setup complete\"")
64+
require.NoError(t, err)
65+
mqttClient.AssertExpectations(t)
66+
})
67+
68+
t.Run("Shutdown", func(t *testing.T) {
69+
mqttClient.On("Disconnect", uint(100)).Return()
70+
w.Stop()
71+
mqttClient.AssertExpectations(t)
72+
})
73+
}
74+
1375
func TestParseStartupMessage(t *testing.T) {
1476
input := "logs message=\"garden-controller setup complete\""
1577
msg := parseStartupMessage(input)
@@ -28,7 +90,7 @@ func TestSendGardenStartupMessage_WarnLogs(t *testing.T) {
2890
"NotificationsDisabled",
2991
&pkg.Garden{},
3092
"", "",
31-
`level=WARN msg="garden does not have controller_startup notification enabled" topic="" garden_id=00000000000000000000
93+
`level=WARN msg="garden does not have controller_startup notification enabled" garden_id=00000000000000000000 topic=""
3294
`,
3395
},
3496
}

0 commit comments

Comments
 (0)