Repository: TwiN/gatus

Issue:
feat(alerting): Add support for HomeAssistant as an alerting provider
### Describe the feature request

Add support for HomeAssistant as an alerting provider

### Why do you personally want this feature to be implemented?

_No response_

### How long have you been using this project?

_No response_

### Additional information

_No response_

Hints from issue comments:
@mostlychris: This would be awesome!@volodimyr: I think I've managed to get the alert on my dockerised home assistant. Not sure about the structure though. LMK what do you think.

<img width="677" alt="Image" src="https://github.com/user-attachments/assets/0a19aa70-c8fd-4e8d-af73-6f3d3f82eb59" />



Solution patch:
diff --git a/README.md b/README.md
index a809ae42..bc52cf04 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
     - [Configuring GitLab alerts](#configuring-gitlab-alerts)
     - [Configuring Google Chat alerts](#configuring-google-chat-alerts)
     - [Configuring Gotify alerts](#configuring-gotify-alerts)
+    - [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts)
     - [Configuring Incident.io alerts](#configuring-incidentio-alerts)
     - [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
     - [Configuring Matrix alerts](#configuring-matrix-alerts)
@@ -604,6 +605,7 @@ endpoints:
 | `alerting.telegram`        | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts).                     | `{}`    |
 | `alerting.twilio`          | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts).                                | `{}`    |
 | `alerting.zulip`           | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts).                              | `{}`    |
+| `alerting.homeassistant`   | Configuration for alerts of type `homeassistant`. <br />See [Configuring HomeAssistant alerts](#configuring-homeassistant-alerts).        | `{}`    |
 
 
 #### Configuring AWS SES alerts
@@ -920,6 +922,75 @@ Here's an example of what the notifications look like:
 ![Gotify notifications](.github/assets/gotify-alerts.png)
 
 
+#### Configuring HomeAssistant alerts
+To configure HomeAssistant alerts, you'll need to add the following to your configuration file:
+
+```yaml
+alerting:
+  homeassistant:
+    url: "http://homeassistant:8123"  # URL of your HomeAssistant instance
+    token: "YOUR_LONG_LIVED_ACCESS_TOKEN"  # Long-lived access token from HomeAssistant
+
+endpoints:
+  - name: my-service
+    url: "https://my-service.com"
+    interval: 5m
+    conditions:
+      - "[STATUS] == 200"
+    alerts:
+      - type: homeassistant
+        enabled: true
+        send-on-resolved: true
+        description: "My service health check"
+        failure-threshold: 3
+        success-threshold: 2
+```
+
+The alerts will be sent as events to HomeAssistant with the event type `gatus_alert`. The event data includes:
+- `status`: "triggered" or "resolved"
+- `endpoint`: The name of the monitored endpoint
+- `description`: The alert description if provided
+- `conditions`: List of conditions and their results
+- `failure_count`: Number of consecutive failures (when triggered)
+- `success_count`: Number of consecutive successes (when resolved)
+
+You can use these events in HomeAssistant automations to:
+- Send notifications
+- Control devices
+- Trigger scenes
+- Log to history
+- And more
+
+Example HomeAssistant automation:
+```yaml
+automation:
+  - alias: "Gatus Alert Handler"
+    trigger:
+      platform: event
+      event_type: gatus_alert
+    action:
+      - service: notify.notify
+        data_template:
+          title: "Gatus Alert: {{ trigger.event.data.endpoint }}"
+          message: >
+            Status: {{ trigger.event.data.status }}
+            {% if trigger.event.data.description %}
+            Description: {{ trigger.event.data.description }}
+            {% endif %}
+            {% for condition in trigger.event.data.conditions %}
+            {{ '✅' if condition.success else '❌' }} {{ condition.condition }}
+            {% endfor %}
+```
+
+To get your HomeAssistant long-lived access token:
+1. Open HomeAssistant
+2. Click on your profile name (bottom left)
+3. Scroll down to "Long-Lived Access Tokens"
+4. Click "Create Token"
+5. Give it a name (e.g., "Gatus")
+6. Copy the token - you'll only see it once!
+
+
 #### Configuring Incident.io alerts
 | Parameter                                | Description                                                                                | Default       |
 |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
diff --git a/alerting/alert/type.go b/alerting/alert/type.go
index 3875d4d8..1f36d6e1 100644
--- a/alerting/alert/type.go
+++ b/alerting/alert/type.go
@@ -32,6 +32,9 @@ const (
 	// TypeGotify is the Type for the gotify alerting provider
 	TypeGotify Type = "gotify"
 
+	// TypeHomeAssistant is the Type for the homeassistant alerting provider
+	TypeHomeAssistant Type = "homeassistant"
+
 	// TypeIncidentIO is the Type for the incident-io alerting provider
 	TypeIncidentIO Type = "incident-io"
 
diff --git a/alerting/config.go b/alerting/config.go
index 8378a840..58c1d647 100644
--- a/alerting/config.go
+++ b/alerting/config.go
@@ -15,6 +15,7 @@ import (
 	"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
 	"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
 	"github.com/TwiN/gatus/v5/alerting/provider/gotify"
+	"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
 	"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
 	"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
 	"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -62,6 +63,9 @@ type Config struct {
 	// Gotify is the configuration for the gotify alerting provider
 	Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
 
+	// HomeAssistant is the configuration for the homeassistant alerting provider
+	HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
+
 	// IncidentIO is the configuration for the incident-io alerting provider
 	IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
 
diff --git a/alerting/provider/homeassistant/homeassistant.go b/alerting/provider/homeassistant/homeassistant.go
new file mode 100644
index 00000000..1b4ad684
--- /dev/null
+++ b/alerting/provider/homeassistant/homeassistant.go
@@ -0,0 +1,196 @@
+package homeassistant
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/TwiN/gatus/v5/alerting/alert"
+	"github.com/TwiN/gatus/v5/client"
+	"github.com/TwiN/gatus/v5/config/endpoint"
+	"gopkg.in/yaml.v3"
+)
+
+var (
+	ErrURLNotSet              = errors.New("url not set")
+	ErrTokenNotSet            = errors.New("token not set")
+	ErrDuplicateGroupOverride = errors.New("duplicate group override")
+)
+
+type Config struct {
+	URL   string `yaml:"url"`
+	Token string `yaml:"token"`
+}
+
+func (cfg *Config) Validate() error {
+	if len(cfg.URL) == 0 {
+		return ErrURLNotSet
+	}
+	if len(cfg.Token) == 0 {
+		return ErrTokenNotSet
+	}
+	return nil
+}
+
+func (cfg *Config) Merge(override *Config) {
+	if len(override.URL) > 0 {
+		cfg.URL = override.URL
+	}
+	if len(override.Token) > 0 {
+		cfg.Token = override.Token
+	}
+}
+
+// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
+type AlertProvider struct {
+	DefaultConfig Config `yaml:",inline"`
+
+	// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
+	DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
+
+	// Overrides is a list of Override that may be prioritized over the default configuration
+	Overrides []Override `yaml:"overrides,omitempty"`
+}
+
+// Override is a case under which the default integration is overridden
+type Override struct {
+	Group  string `yaml:"group"`
+	Config `yaml:",inline"`
+}
+
+// Validate the provider's configuration
+func (provider *AlertProvider) Validate() error {
+	registeredGroups := make(map[string]bool)
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
+				return ErrDuplicateGroupOverride
+			}
+			registeredGroups[override.Group] = true
+		}
+	}
+	return provider.DefaultConfig.Validate()
+}
+
+// Send an alert using the provider
+func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
+	cfg, err := provider.GetConfig(ep.Group, alert)
+	if err != nil {
+		return err
+	}
+	buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
+	request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer)
+	if err != nil {
+		return err
+	}
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("Authorization", "Bearer "+cfg.Token)
+	response, err := client.GetHTTPClient(nil).Do(request)
+	if err != nil {
+		return err
+	}
+	defer response.Body.Close()
+	if response.StatusCode > 399 {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
+	}
+	return err
+}
+
+type Body struct {
+	EventType string `json:"event_type"`
+	EventData struct {
+		Status      string `json:"status"`
+		Endpoint    string `json:"endpoint"`
+		Description string `json:"description,omitempty"`
+		Conditions  []struct {
+			Condition string `json:"condition"`
+			Success   bool   `json:"success"`
+		} `json:"conditions,omitempty"`
+		FailureCount int `json:"failure_count,omitempty"`
+		SuccessCount int `json:"success_count,omitempty"`
+	} `json:"event_data"`
+}
+
+// buildRequestBody builds the request body for the provider
+func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
+	body := Body{
+		EventType: "gatus_alert",
+		EventData: struct {
+			Status      string `json:"status"`
+			Endpoint    string `json:"endpoint"`
+			Description string `json:"description,omitempty"`
+			Conditions  []struct {
+				Condition string `json:"condition"`
+				Success   bool   `json:"success"`
+			} `json:"conditions,omitempty"`
+			FailureCount int `json:"failure_count,omitempty"`
+			SuccessCount int `json:"success_count,omitempty"`
+		}{
+			Status:   "resolved",
+			Endpoint: ep.DisplayName(),
+		},
+	}
+
+	if !resolved {
+		body.EventData.Status = "triggered"
+		body.EventData.FailureCount = alert.FailureThreshold
+	} else {
+		body.EventData.SuccessCount = alert.SuccessThreshold
+	}
+
+	if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
+		body.EventData.Description = alertDescription
+	}
+
+	if len(result.ConditionResults) > 0 {
+		for _, conditionResult := range result.ConditionResults {
+			body.EventData.Conditions = append(body.EventData.Conditions, struct {
+				Condition string `json:"condition"`
+				Success   bool   `json:"success"`
+			}{
+				Condition: conditionResult.Condition,
+				Success:   conditionResult.Success,
+			})
+		}
+	}
+
+	bodyAsJSON, _ := json.Marshal(body)
+	return bodyAsJSON
+}
+
+// GetDefaultAlert returns the provider's default alert configuration
+func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
+	return provider.DefaultAlert
+}
+
+// GetConfig returns the configuration for the provider with the overrides applied
+func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
+	cfg := provider.DefaultConfig
+	if provider.Overrides != nil {
+		for _, override := range provider.Overrides {
+			if group == override.Group {
+				cfg.Merge(&override.Config)
+				break
+			}
+		}
+	}
+	if len(alert.ProviderOverride) != 0 {
+		overrideConfig := Config{}
+		if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
+			return nil, err
+		}
+		cfg.Merge(&overrideConfig)
+	}
+	err := cfg.Validate()
+	return &cfg, err
+}
+
+// ValidateOverrides validates the alert's provider override and, if present, the group override
+func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
+	_, err := provider.GetConfig(group, alert)
+	return err
+}
diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go
index 94e600f0..b1c3bfaa 100644
--- a/alerting/provider/provider.go
+++ b/alerting/provider/provider.go
@@ -10,6 +10,7 @@ import (
 	"github.com/TwiN/gatus/v5/alerting/provider/github"
 	"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
 	"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
+	"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
 	"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
 	"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
 	"github.com/TwiN/gatus/v5/alerting/provider/matrix"
@@ -80,6 +81,7 @@ var (
 	_ AlertProvider = (*github.AlertProvider)(nil)
 	_ AlertProvider = (*gitlab.AlertProvider)(nil)
 	_ AlertProvider = (*googlechat.AlertProvider)(nil)
+	_ AlertProvider = (*homeassistant.AlertProvider)(nil)
 	_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
 	_ AlertProvider = (*matrix.AlertProvider)(nil)
 	_ AlertProvider = (*mattermost.AlertProvider)(nil)
@@ -105,6 +107,7 @@ var (
 	_ Config[github.Config]         = (*github.Config)(nil)
 	_ Config[gitlab.Config]         = (*gitlab.Config)(nil)
 	_ Config[googlechat.Config]     = (*googlechat.Config)(nil)
+	_ Config[homeassistant.Config]  = (*homeassistant.Config)(nil)
 	_ Config[incidentio.Config]     = (*incidentio.Config)(nil)
 	_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
 	_ Config[matrix.Config]         = (*matrix.Config)(nil)
diff --git a/config/config.go b/config/config.go
index 701dd84e..e31a4225 100644
--- a/config/config.go
+++ b/config/config.go
@@ -411,6 +411,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
 		alert.TypeGitea,
 		alert.TypeGoogleChat,
 		alert.TypeGotify,
+		alert.TypeHomeAssistant,
 		alert.TypeJetBrainsSpace,
 		alert.TypeMatrix,
 		alert.TypeMattermost,



Test patch:
diff --git a/alerting/provider/homeassistant/homeassistant_test.go b/alerting/provider/homeassistant/homeassistant_test.go
new file mode 100644
index 00000000..3bc04ac5
--- /dev/null
+++ b/alerting/provider/homeassistant/homeassistant_test.go
@@ -0,0 +1,158 @@
+package homeassistant
+
+import (
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/TwiN/gatus/v5/alerting/alert"
+	"github.com/TwiN/gatus/v5/client"
+	"github.com/TwiN/gatus/v5/config/endpoint"
+	"github.com/TwiN/gatus/v5/test"
+)
+
+func TestAlertProvider_Validate(t *testing.T) {
+	invalidProvider := AlertProvider{DefaultConfig: Config{URL: "", Token: ""}}
+	if err := invalidProvider.Validate(); err == nil {
+		t.Error("provider shouldn't have been valid")
+	}
+	invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
+	if err := invalidProviderNoToken.Validate(); err == nil {
+		t.Error("provider shouldn't have been valid")
+	}
+	validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
+	if err := validProvider.Validate(); err != nil {
+		t.Error("provider should've been valid")
+	}
+}
+
+func TestAlertProvider_ValidateWithOverride(t *testing.T) {
+	providerWithInvalidOverrideGroup := AlertProvider{
+		Overrides: []Override{
+			{
+				Config: Config{URL: "http://homeassistant:8123", Token: "token"},
+				Group:  "",
+			},
+		},
+	}
+	if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
+		t.Error("provider Group shouldn't have been valid")
+	}
+	providerWithValidOverride := AlertProvider{
+		DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
+		Overrides: []Override{
+			{
+				Config: Config{URL: "http://homeassistant:8123", Token: "token"},
+				Group:  "group",
+			},
+		},
+	}
+	if err := providerWithValidOverride.Validate(); err != nil {
+		t.Error("provider should've been valid")
+	}
+}
+
+func TestAlertProvider_Send(t *testing.T) {
+	defer client.InjectHTTPClient(nil)
+	firstDescription := "description-1"
+	secondDescription := "description-2"
+	scenarios := []struct {
+		Name             string
+		Provider         AlertProvider
+		Alert            alert.Alert
+		Resolved         bool
+		MockRoundTripper test.MockRoundTripper
+		ExpectedError    bool
+	}{
+		{
+			Name:     "triggered",
+			Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
+			Alert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+			}),
+			ExpectedError: false,
+		},
+		{
+			Name:     "triggered-error",
+			Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
+			Alert:    alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved: false,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
+			}),
+			ExpectedError: true,
+		},
+		{
+			Name:     "resolved",
+			Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
+			Alert:    alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
+			Resolved: true,
+			MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
+				return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
+			}),
+			ExpectedError: false,
+		},
+	}
+	for _, scenario := range scenarios {
+		t.Run(scenario.Name, func(t *testing.T) {
+			client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
+			err := scenario.Provider.Send(
+				&endpoint.Endpoint{Name: "endpoint-name"},
+				&scenario.Alert,
+				&endpoint.Result{
+					ConditionResults: []*endpoint.ConditionResult{
+						{Condition: "SUCCESSFUL_CONDITION", Success: true},
+						{Condition: "FAILING_CONDITION", Success: false},
+					},
+				},
+				scenario.Resolved,
+			)
+			if scenario.ExpectedError && err == nil {
+				t.Error("expected error, got none")
+			}
+			if !scenario.ExpectedError && err != nil {
+				t.Error("expected no error, got", err.Error())
+			}
+		})
+	}
+}
+
+func TestAlertProvider_buildRequestBody(t *testing.T) {
+	description := "test-description"
+	provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
+	body := provider.buildRequestBody(
+		&endpoint.Endpoint{Name: "endpoint-name"},
+		&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
+		&endpoint.Result{
+			ConditionResults: []*endpoint.ConditionResult{
+				{Condition: "SUCCESSFUL_CONDITION", Success: true},
+				{Condition: "FAILING_CONDITION", Success: false},
+			},
+		},
+		false,
+	)
+	var decodedBody Body
+	if err := json.Unmarshal(body, &decodedBody); err != nil {
+		t.Error("expected body to be valid JSON, got error:", err.Error())
+	}
+	if decodedBody.EventType != "gatus_alert" {
+		t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
+	}
+	if decodedBody.EventData.Status != "triggered" {
+		t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
+	}
+	if decodedBody.EventData.Description != description {
+		t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
+	}
+	if len(decodedBody.EventData.Conditions) != 2 {
+		t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
+	}
+	if !decodedBody.EventData.Conditions[0].Success {
+		t.Error("expected first condition to be successful")
+	}
+	if decodedBody.EventData.Conditions[1].Success {
+		t.Error("expected second condition to be unsuccessful")
+	}
+}