diff --git a/garden-app/integration_tests/main_test.go b/garden-app/integration_tests/main_test.go index 002baeca..e3ed1676 100644 --- a/garden-app/integration_tests/main_test.go +++ b/garden-app/integration_tests/main_test.go @@ -247,7 +247,7 @@ func GardenTests(t *testing.T) { // The health status timing can be inconsistent, so it should be retried retries := 1 - for g.Health.Status != "UP" && retries <= 5 { + for g.Health.Status != pkg.HealthStatusUp && retries <= 5 { time.Sleep(time.Duration(retries) * time.Second) status, err := makeRequest(http.MethodGet, "/gardens/"+gardenID, http.NoBody, &g) @@ -257,7 +257,7 @@ func GardenTests(t *testing.T) { retries++ } - assert.Equal(t, "UP", g.Health.Status) + assert.Equal(t, pkg.HealthStatusUp, g.Health.Status) assert.Equal(t, 50.0, g.TemperatureHumidityData.TemperatureCelsius) assert.Equal(t, 50.0, g.TemperatureHumidityData.HumidityPercentage) }) diff --git a/garden-app/pkg/garden.go b/garden-app/pkg/garden.go index ee7c4d10..79e01c7a 100644 --- a/garden-app/pkg/garden.go +++ b/garden-app/pkg/garden.go @@ -12,6 +12,14 @@ import ( "github.com/calvinmclean/babyapi" ) +type HealthStatus string + +const ( + HealthStatusDown HealthStatus = "DOWN" + HealthStatusUp HealthStatus = "UP" + HealthStatusUnknown HealthStatus = "N/A" +) + // Garden is the representation of a single garden-controller device type Garden struct { Name string `json:"name" yaml:"name,omitempty"` @@ -35,9 +43,9 @@ func (g *Garden) String() string { // GardenHealth holds information about the Garden controller's health status type GardenHealth struct { - Status string `json:"status,omitempty"` - Details string `json:"details,omitempty"` - LastContact *time.Time `json:"last_contact,omitempty"` + Status HealthStatus `json:"status,omitempty"` + Details string `json:"details,omitempty"` + LastContact *time.Time `json:"last_contact,omitempty"` } // Health returns a GardenHealth struct after querying InfluxDB for the Garden controller's last contact time @@ -52,7 +60,7 @@ func (g *Garden) Health(ctx context.Context, influxdbClient influxdb.Client) *Ga if lastContact.IsZero() { return &GardenHealth{ - Status: "DOWN", + Status: HealthStatusDown, Details: "no last contact time available", } } @@ -61,9 +69,9 @@ func (g *Garden) Health(ctx context.Context, influxdbClient influxdb.Client) *Ga between := time.Since(lastContact) up := between < 5*time.Minute - status := "UP" + status := HealthStatusUp if !up { - status = "DOWN" + status = HealthStatusDown } return &GardenHealth{ diff --git a/garden-app/pkg/garden_test.go b/garden-app/pkg/garden_test.go index aa3d0ad9..70290fc1 100644 --- a/garden-app/pkg/garden_test.go +++ b/garden-app/pkg/garden_test.go @@ -17,31 +17,31 @@ func TestHealth(t *testing.T) { name string lastContactTime time.Time err error - expectedStatus string + expectedStatus HealthStatus }{ { "GardenIsUp", time.Now(), nil, - "UP", + HealthStatusUp, }, { "GardenIsDown", time.Now().Add(-5 * time.Minute), nil, - "DOWN", + HealthStatusDown, }, { "InfluxDBError", time.Time{}, errors.New("influxdb error"), - "N/A", + HealthStatusUnknown, }, { "ZeroTime", time.Time{}, nil, - "DOWN", + HealthStatusDown, }, } diff --git a/garden-app/pkg/notifications/fake/fake.go b/garden-app/pkg/notifications/fake/fake.go index 6075aca3..6b0bd14b 100644 --- a/garden-app/pkg/notifications/fake/fake.go +++ b/garden-app/pkg/notifications/fake/fake.go @@ -22,9 +22,8 @@ type Message struct { } var ( - // lastMessage allows checking the last message that was sent - lastMessage = Message{} - lastMessageMtx = sync.Mutex{} + messages = []Message{} + messagesMtx = sync.Mutex{} ) func NewClient(options map[string]interface{}) (*Client, error) { @@ -46,21 +45,33 @@ func (c *Client) SendMessage(title, message string) error { if c.SendMessageError != "" { return errors.New(c.SendMessageError) } - lastMessageMtx.Lock() - lastMessage = Message{title, message} - lastMessageMtx.Unlock() + messagesMtx.Lock() + messages = append(messages, Message{title, message}) + messagesMtx.Unlock() return nil } func LastMessage() Message { - lastMessageMtx.Lock() - result := lastMessage - lastMessageMtx.Unlock() + messagesMtx.Lock() + defer messagesMtx.Unlock() + + if len(messages) == 0 { + return Message{} + } + result := messages[len(messages)-1] + return result +} + +func Messages() []Message { + messagesMtx.Lock() + result := make([]Message, len(messages)) + copy(result, messages) + messagesMtx.Unlock() return result } func ResetLastMessage() { - lastMessageMtx.Lock() - lastMessage = Message{} - lastMessageMtx.Unlock() + messagesMtx.Lock() + messages = []Message{} + messagesMtx.Unlock() } diff --git a/garden-app/worker/notifications.go b/garden-app/worker/notifications.go index 7177141e..4735ecf7 100644 --- a/garden-app/worker/notifications.go +++ b/garden-app/worker/notifications.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/calvinmclean/automated-garden/garden-app/pkg" ) @@ -14,10 +15,23 @@ func (w *Worker) sendLightActionNotification(g *pkg.Garden, state pkg.LightState } title := fmt.Sprintf("%s: Light %s", g.Name, state.String()) - w.sendNotificationWithClientID(g.LightSchedule.GetNotificationClientID(), title, "Successfully executed LightAction", logger) + w.sendNotification(g.LightSchedule.GetNotificationClientID(), title, "Successfully executed LightAction", logger) } -func (w *Worker) sendNotificationWithClientID(clientID, title, msg string, logger *slog.Logger) { +func (w *Worker) sendDownNotification(g *pkg.Garden, clientID, actionName string) { + health := g.Health(context.Background(), w.influxdbClient) + if health.Status != pkg.HealthStatusUp { + w.sendNotification( + clientID, + fmt.Sprintf("%s: %s", g.Name, health.Status), + fmt.Sprintf(`Attempting to execute %s Action, but last contact was %s. +Details: %s`, actionName, health.LastContact.Format(time.DateTime), health.Details), + w.logger, + ) + } +} + +func (w *Worker) sendNotification(clientID, title, msg string, logger *slog.Logger) { ncLogger := logger.With("notification_client_id", clientID) notificationClient, err := w.storageClient.NotificationClientConfigs.Get(context.Background(), clientID) diff --git a/garden-app/worker/scheduler.go b/garden-app/worker/scheduler.go index 1f1d0dff..ced889d2 100644 --- a/garden-app/worker/scheduler.go +++ b/garden-app/worker/scheduler.go @@ -76,7 +76,7 @@ func (w *Worker) ScheduleWaterAction(waterSchedule *pkg.WaterSchedule) error { jobLogger.Error("error executing scheduled water action", "error", err, "zone_id", zg.Zone.ID.String()) schedulerErrors.WithLabelValues(zoneLabels(zg.Zone)...).Inc() if ws.GetNotificationClientID() != "" { - go w.sendNotificationWithClientID( + go w.sendNotification( ws.GetNotificationClientID(), fmt.Sprintf("%s: Water Action Error", ws.Name), err.Error(), @@ -91,7 +91,7 @@ func (w *Worker) ScheduleWaterAction(waterSchedule *pkg.WaterSchedule) error { jobLogger.Error("error executing schedule WaterAction", "error", err) schedulerErrors.WithLabelValues(waterScheduleLabels(waterSchedule)...).Inc() if waterSchedule.GetNotificationClientID() != "" { - w.sendNotificationWithClientID( + w.sendNotification( waterSchedule.GetNotificationClientID(), fmt.Sprintf("%s: Water Action Error", waterSchedule.Name), err.Error(), @@ -480,13 +480,18 @@ func waterScheduleLabels(ws *pkg.WaterSchedule) []string { func (w *Worker) executeLightActionInScheduledJob(g *pkg.Garden, input *action.LightAction, actionLogger *slog.Logger) { actionLogger = actionLogger.With("state", input.State.String()) actionLogger.Info("executing LightAction") + + if g.LightSchedule.GetNotificationClientID() != "" { + w.sendDownNotification(g, g.LightSchedule.GetNotificationClientID(), "Light") + } + err := w.ExecuteLightAction(g, input) if err != nil { actionLogger.Error("error executing scheduled LightAction", "error", err) schedulerErrors.WithLabelValues(gardenLabels(g)...).Inc() if g.LightSchedule.GetNotificationClientID() != "" { - w.sendNotificationWithClientID(g.LightSchedule.GetNotificationClientID(), fmt.Sprintf("%s: Light Action Error", g.Name), err.Error(), actionLogger) + w.sendNotification(g.LightSchedule.GetNotificationClientID(), fmt.Sprintf("%s: Light Action Error", g.Name), err.Error(), actionLogger) } return } diff --git a/garden-app/worker/scheduler_test.go b/garden-app/worker/scheduler_test.go index 2fb50092..f86f0d25 100644 --- a/garden-app/worker/scheduler_test.go +++ b/garden-app/worker/scheduler_test.go @@ -19,6 +19,7 @@ import ( "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) var ( @@ -175,6 +176,99 @@ func TestScheduleWaterAction(t *testing.T) { mqttClient.AssertExpectations(t) } +func TestScheduleWaterActionGardenHealthNotification(t *testing.T) { + tests := []struct { + name string + lastContact time.Time + expectedMessages []fake.Message + }{ + { + "GardenUp", + time.Now(), + []fake.Message{}, + }, + { + "GardenDown", + time.Now().Add(-10 * time.Minute), + []fake.Message{ + { + Title: "test-garden: DOWN", + Message: `Attempting to execute Water Action, but last contact was \d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d.\nDetails: last contact from Garden was 10m1.\d+s ago`, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake.ResetLastMessage() + + storageClient, err := storage.NewClient(storage.Config{ + Driver: "hashmap", + }) + assert.NoError(t, err) + defer weather.ResetCache() + + garden := createExampleGarden() + zone := createExampleZone() + + notificationClient := ¬ifications.Client{ + ID: babyapi.NewID(), + Name: "TestClient", + Type: "fake", + Options: map[string]any{}, + } + err = storageClient.NotificationClientConfigs.Set(context.Background(), notificationClient) + assert.NoError(t, err) + + err = storageClient.Gardens.Set(context.Background(), garden) + assert.NoError(t, err) + + err = storageClient.Zones.Set(context.Background(), zone) + assert.NoError(t, err) + + mqttClient := new(mqtt.MockClient) + mqttClient.On("WaterTopic", mock.Anything).Return("test-garden/action/water", nil) + mqttClient.On("Publish", "test-garden/action/water", mock.Anything).Return(nil) + mqttClient.On("Disconnect", uint(100)).Return() + + influxdbClient := new(influxdb.MockClient) + influxdbClient.On("GetLastContact", mock.Anything, mock.Anything).Return(tt.lastContact, nil) + influxdbClient.On("Close").Return() + + worker := NewWorker(storageClient, influxdbClient, mqttClient, slog.Default()) + worker.StartAsync() + + ws := createExampleWaterSchedule() + ws.Name = "MyWaterSchedule" + // Set StartTime to the near future + ws.StartTime = pkg.NewStartTime(time.Now().Add(1 * time.Second)) + ncID := notificationClient.GetID() + ws.NotificationClientID = &ncID + + err = storageClient.WaterSchedules.Set(context.Background(), ws) + assert.NoError(t, err) + + err = worker.ScheduleWaterAction(ws) + assert.NoError(t, err) + + time.Sleep(1000 * time.Millisecond) + + worker.Stop() + influxdbClient.AssertExpectations(t) + mqttClient.AssertExpectations(t) + + for i, msg := range fake.Messages() { + require.Equal(t, tt.expectedMessages[i].Title, msg.Title) + require.Regexp(t, tt.expectedMessages[i].Message, msg.Message) + } + if len(tt.expectedMessages) == 0 { + require.Empty(t, fake.Messages()) + } + }) + } +} + func TestScheduleWaterActionWithErrorNotification(t *testing.T) { tests := []struct { name string @@ -213,12 +307,15 @@ func TestScheduleWaterActionWithErrorNotification(t *testing.T) { err = storageClient.Zones.Set(context.Background(), zone) assert.NoError(t, err) - influxdbClient := new(influxdb.MockClient) mqttClient := new(mqtt.MockClient) - mqttClient.On("WaterTopic", mock.Anything).Return("test-garden/action/water", nil) mqttClient.On("Publish", "test-garden/action/water", mock.Anything).Return(errors.New("publish error")) mqttClient.On("Disconnect", uint(100)).Return() + + influxdbClient := new(influxdb.MockClient) + if tt.enableNotification { + influxdbClient.On("GetLastContact", mock.Anything, mock.Anything).Return(time.Now(), nil) + } influxdbClient.On("Close").Return() worker := NewWorker(storageClient, influxdbClient, mqttClient, slog.Default()) @@ -555,6 +652,10 @@ func TestScheduleLightActions(t *testing.T) { mqttClient.On("Publish", "test-garden/action/light", mock.Anything).Return(tt.mqttPublishError) mqttClient.On("Disconnect", uint(100)).Return() + influxdbClient := new(influxdb.MockClient) + influxdbClient.On("GetLastContact", mock.Anything, mock.Anything).Return(time.Now(), nil) + influxdbClient.On("Close").Return() + notificationClient := ¬ifications.Client{ ID: babyapi.NewID(), Name: "TestClient", @@ -564,7 +665,7 @@ func TestScheduleLightActions(t *testing.T) { err = storageClient.NotificationClientConfigs.Set(context.Background(), notificationClient) assert.NoError(t, err) - worker := NewWorker(storageClient, nil, mqttClient, slog.Default()) + worker := NewWorker(storageClient, influxdbClient, mqttClient, slog.Default()) worker.StartAsync() defer worker.Stop() @@ -592,6 +693,84 @@ func TestScheduleLightActions(t *testing.T) { }) } }) + + t.Run("ScheduledLightActionGardenDownNotification", func(t *testing.T) { + tests := []struct { + name string + lastContact time.Time + expectedMessages []fake.Message + }{ + { + "GardenUp", + time.Now(), + []fake.Message{ + {Title: "test-garden: Light ON", Message: "Successfully executed LightAction"}, + }, + }, + { + "GardenDown", + time.Now().Add(-10 * time.Minute), + []fake.Message{ + { + Title: "test-garden: DOWN", + Message: `Attempting to execute Light Action, but last contact was \d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d.\nDetails: last contact from Garden was 10m1.\d+s ago`, + }, + {Title: "test-garden: Light ON", Message: "Successfully executed LightAction"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake.ResetLastMessage() + + storageClient, err := storage.NewClient(storage.Config{ + Driver: "hashmap", + }) + assert.NoError(t, err) + + mqttClient := new(mqtt.MockClient) + mqttClient.On("LightTopic", mock.Anything).Return("test-garden/action/light", nil) + mqttClient.On("Publish", "test-garden/action/light", mock.Anything).Return(nil) + mqttClient.On("Disconnect", uint(100)).Return() + + influxdbClient := new(influxdb.MockClient) + influxdbClient.On("GetLastContact", mock.Anything, mock.Anything).Return(tt.lastContact, nil) + influxdbClient.On("Close").Return() + + notificationClient := ¬ifications.Client{ + ID: babyapi.NewID(), + Name: "TestClient", + Type: "fake", + Options: map[string]any{}, + } + err = storageClient.NotificationClientConfigs.Set(context.Background(), notificationClient) + assert.NoError(t, err) + + worker := NewWorker(storageClient, influxdbClient, mqttClient, slog.Default()) + worker.StartAsync() + defer worker.Stop() + + // Create new LightSchedule that turns on in 1 second for only 1 second + now := time.Now().UTC() + later := now.Add(1 * time.Second).Truncate(time.Second) + g := createExampleGarden() + g.LightSchedule.StartTime = pkg.NewStartTime(later) + g.LightSchedule.Duration = &pkg.Duration{Duration: time.Second} + ncID := notificationClient.GetID() + g.LightSchedule.NotificationClientID = &ncID + + err = worker.ScheduleLightActions(g) + assert.NoError(t, err) + + time.Sleep(1 * time.Second) + for i, msg := range fake.Messages() { + require.Equal(t, tt.expectedMessages[i].Title, msg.Title) + require.Regexp(t, tt.expectedMessages[i].Message, msg.Message) + } + }) + } + }) } func TestScheduleLightDelay(t *testing.T) { diff --git a/garden-app/worker/water_schedule_actions.go b/garden-app/worker/water_schedule_actions.go index a2cb21eb..f5ecca85 100644 --- a/garden-app/worker/water_schedule_actions.go +++ b/garden-app/worker/water_schedule_actions.go @@ -32,6 +32,10 @@ func (w *Worker) ExecuteScheduledWaterAction(g *pkg.Garden, z *pkg.Zone, ws *pkg return nil } + if ws.GetNotificationClientID() != "" { + w.sendDownNotification(g, ws.GetNotificationClientID(), "Water") + } + return w.ExecuteWaterAction(g, z, &action.WaterAction{ Duration: &pkg.Duration{Duration: duration}, })