Add comprehensive MQTT and webhook integration for Home Assistant and external services:
MQTT Client (internal/mqtt/client.go):
- Optional MQTT client with exponential backoff reconnect (5s-120s)
- TLS support for mqtts:// connections
- Home Assistant auto-discovery for persons, zones, fall detection, system health, system mode
- Topic structure: spaxel/{mothership_id}/person/{id}/presence, zone/{id}/occupancy, etc.
- LWT (Last Will and Testament) for availability
- Dynamic configuration updates via API
- Retained messages for presence and occupancy states
MQTT Publisher (internal/mqtt/publisher.go):
- Event bus subscriber publishing zone entry/exit, fall alerts, anomalies
- Person presence tracking across zones with home/not_home states
- Zone occupancy counting with occupants list
- Periodic system health publishing (60s interval)
- HA discovery methods for all entity types
- Person and zone discovery removal on delete
System Webhook (internal/webhook/publisher.go):
- Single webhook URL receiving all events with X-Spaxel-Event header
- JSON payload with event_type, timestamp, zone, person, blob_id, severity, detail
- Retry policy: one retry after 30s on 5xx errors
- Test webhook endpoint for configuration verification
API Integration Handler (internal/api/integrations.go):
- GET/POST /api/settings/integration for MQTT and webhook configuration
- POST /api/settings/integration/test for testing connections
- Settings persisted in database settings table
- Integration with existing MQTTClient and WebhookPublisher interfaces
Dashboard Integration UI (dashboard/integrations.html, js/integrations.js):
- Settings panel with MQTT broker URL, username, password (masked), TLS toggle
- Discovery prefix configuration
- Test Connection and Publish Discovery buttons
- System webhook URL configuration with enable toggle
- Connection status indicator with error messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
15 KiB
Go
497 lines
15 KiB
Go
// Package api provides REST API handlers for Spaxel integration settings.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// IntegrationSettingsHandler manages home automation integration settings.
|
|
type IntegrationSettingsHandler struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
|
|
// MQTT configuration (managed via settings table)
|
|
mqttClient MQTTClient
|
|
|
|
// System webhook publisher
|
|
webhookPublisher WebhookPublisher
|
|
|
|
// Mothership ID for HA entity IDs
|
|
mothershipID string
|
|
}
|
|
|
|
// MQTTClient is the interface for MQTT operations.
|
|
type MQTTClient interface {
|
|
IsConnected() bool
|
|
GetMothershipID() string
|
|
GetConfig() interface{}
|
|
UpdateConfig(ctx context.Context, cfg interface{}) error
|
|
Reconnect(ctx context.Context) error
|
|
PublishDiscoveryNow() error
|
|
PublishPersonPresenceDiscovery(personID, personName string) error
|
|
PublishZoneOccupancyDiscovery(zoneID, zoneName string) error
|
|
PublishZoneBinaryDiscovery(zoneID, zoneName string) error
|
|
PublishFallDetectionDiscovery() error
|
|
PublishSystemHealthDiscovery() error
|
|
PublishSystemModeDiscovery() error
|
|
RemovePersonDiscovery(personID string) error
|
|
RemoveZoneDiscovery(zoneID string) error
|
|
}
|
|
|
|
// WebhookPublisher is the interface for system webhook operations.
|
|
type WebhookPublisher interface {
|
|
UpdateConfig(cfg interface{})
|
|
GetConfig() interface{}
|
|
TestWebhook() error
|
|
}
|
|
|
|
// NewIntegrationSettingsHandler creates a new integration settings handler.
|
|
func NewIntegrationSettingsHandler(db *sql.DB, mothershipID string) *IntegrationSettingsHandler {
|
|
return &IntegrationSettingsHandler{
|
|
db: db,
|
|
mothershipID: mothershipID,
|
|
}
|
|
}
|
|
|
|
// SetMQTTClient sets the MQTT client for integration operations.
|
|
func (h *IntegrationSettingsHandler) SetMQTTClient(client MQTTClient) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.mqttClient = client
|
|
}
|
|
|
|
// SetWebhookPublisher sets the webhook publisher.
|
|
func (h *IntegrationSettingsHandler) SetWebhookPublisher(publisher WebhookPublisher) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.webhookPublisher = publisher
|
|
}
|
|
|
|
// RegisterRoutes registers integration settings endpoints.
|
|
//
|
|
// Integration Settings Endpoints:
|
|
//
|
|
// GET /api/settings/integration
|
|
//
|
|
// @Summary Get integration settings
|
|
// @Description Returns MQTT and system webhook configuration.
|
|
// @Tags settings
|
|
// @Produce json
|
|
// @Success 200 {object} integrationSettingsResponse "Integration settings"
|
|
// @Router /api/settings/integration [get]
|
|
//
|
|
// POST /api/settings/integration
|
|
//
|
|
// @Summary Update integration settings
|
|
// @Description Updates MQTT and/or system webhook configuration. Only the fields provided are modified.
|
|
// @Tags settings
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body integrationSettingsRequest true "Settings to update (partial)"
|
|
// @Success 200 {object} integrationSettingsResponse "Updated settings"
|
|
// @Failure 400 {object} map[string]string "Invalid request or validation error"
|
|
// @Router /api/settings/integration [post]
|
|
//
|
|
// POST /api/settings/integration/test
|
|
//
|
|
// @Summary Test integration connection
|
|
// @Description Sends a test event via MQTT or webhook to verify configuration.
|
|
// @Tags settings
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body integrationTestRequest true "Test parameters"
|
|
// @Success 200 {object} map[string]interface{} "Test result"
|
|
// @Failure 400 {object} map[string]string "Invalid request"
|
|
// @Router /api/settings/integration [post]
|
|
func (h *IntegrationSettingsHandler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/api/settings/integration", h.handleGetSettings)
|
|
r.Post("/api/settings/integration", h.handleUpdateSettings)
|
|
r.Post("/api/settings/integration/test", h.handleTest)
|
|
}
|
|
|
|
// integrationSettingsResponse is the response for integration settings.
|
|
type integrationSettingsResponse struct {
|
|
MQTT *mqttConfig `json:"mqtt,omitempty"`
|
|
Webhook *webhookConfig `json:"webhook,omitempty"`
|
|
}
|
|
|
|
// mqttConfig holds MQTT configuration.
|
|
type mqttConfig struct {
|
|
Broker string `json:"broker,omitempty"` // e.g., "tcp://homeassistant.local:1883"
|
|
Username string `json:"username,omitempty"` // MQTT username
|
|
Password string `json:"password,omitempty"` // MQTT password (write-only, never returned)
|
|
TLS bool `json:"tls,omitempty"` // Whether to use TLS
|
|
DiscoveryPrefix string `json:"discovery_prefix,omitempty"` // Home Assistant discovery prefix
|
|
Connected bool `json:"connected"` // Connection status
|
|
MothershipID string `json:"mothership_id,omitempty"` // Unique ID
|
|
}
|
|
|
|
// webhookConfig holds system webhook configuration.
|
|
type webhookConfig struct {
|
|
URL string `json:"url,omitempty"` // Webhook URL
|
|
Enabled bool `json:"enabled"` // Whether webhook is enabled
|
|
}
|
|
|
|
// integrationSettingsRequest is the request body for updating integration settings.
|
|
type integrationSettingsRequest struct {
|
|
MQTT *mqttConfig `json:"mqtt,omitempty"`
|
|
Webhook *webhookConfig `json:"webhook,omitempty"`
|
|
}
|
|
|
|
// integrationTestRequest is the request body for testing integrations.
|
|
type integrationTestRequest struct {
|
|
Type string `json:"type"` // "mqtt" or "webhook"
|
|
Options map[string]interface{} `json:"options"` // Test-specific options
|
|
}
|
|
|
|
// handleGetSettings handles GET /api/settings/integration requests.
|
|
func (h *IntegrationSettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
|
|
response := integrationSettingsResponse{}
|
|
|
|
// Get MQTT settings
|
|
mqttCfg, err := h.getMQTTConfig()
|
|
if err == nil {
|
|
response.MQTT = mqttCfg
|
|
// Set connection status
|
|
if h.mqttClient != nil {
|
|
response.MQTT.Connected = h.mqttClient.IsConnected()
|
|
response.MQTT.MothershipID = h.mqttClient.GetMothershipID()
|
|
}
|
|
}
|
|
|
|
// Get webhook settings
|
|
webhookCfg, err := h.getWebhookConfig()
|
|
if err == nil {
|
|
response.Webhook = webhookCfg
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleUpdateSettings handles POST /api/settings/integration requests.
|
|
func (h *IntegrationSettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req integrationSettingsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
// Update MQTT settings
|
|
if req.MQTT != nil {
|
|
if err := h.updateMQTTSettings(req.MQTT); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Update webhook settings
|
|
if req.Webhook != nil {
|
|
if err := h.updateWebhookSettings(req.Webhook); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Return updated settings
|
|
h.handleGetSettings(w, r)
|
|
}
|
|
|
|
// handleTest handles POST /api/settings/integration/test requests.
|
|
func (h *IntegrationSettingsHandler) handleTest(w http.ResponseWriter, r *http.Request) {
|
|
var req integrationTestRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
return
|
|
}
|
|
|
|
switch req.Type {
|
|
case "mqtt":
|
|
h.testMQTT(w, r)
|
|
case "webhook":
|
|
h.testWebhook(w, r)
|
|
default:
|
|
writeJSONError(w, http.StatusBadRequest, "invalid type: must be 'mqtt' or 'webhook'")
|
|
}
|
|
}
|
|
|
|
// getMQTTConfig retrieves MQTT settings from the database.
|
|
func (h *IntegrationSettingsHandler) getMQTTConfig() (*mqttConfig, error) {
|
|
var cfg mqttConfig
|
|
|
|
// Get settings from database
|
|
var brokerURL, username, discoveryPrefix string
|
|
var tlsEnabled int
|
|
|
|
err := h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'mqtt_broker'`).Scan(&brokerURL)
|
|
if err != nil {
|
|
// Return default config if not found
|
|
cfg.DiscoveryPrefix = "homeassistant"
|
|
return &cfg, nil
|
|
}
|
|
|
|
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'mqtt_username'`).Scan(&username)
|
|
if err != nil {
|
|
username = ""
|
|
}
|
|
|
|
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'mqtt_tls'`).Scan(&tlsEnabled)
|
|
if err != nil {
|
|
tlsEnabled = 0
|
|
}
|
|
|
|
err = h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'mqtt_discovery_prefix'`).Scan(&discoveryPrefix)
|
|
if err != nil {
|
|
discoveryPrefix = "homeassistant"
|
|
}
|
|
|
|
// Parse JSON strings (remove quotes)
|
|
if len(brokerURL) > 0 && brokerURL[0] == '"' {
|
|
brokerURL = brokerURL[1 : len(brokerURL)-1]
|
|
}
|
|
if len(username) > 0 && username[0] == '"' {
|
|
username = username[1 : len(username)-1]
|
|
}
|
|
if len(discoveryPrefix) > 0 && discoveryPrefix[0] == '"' {
|
|
discoveryPrefix = discoveryPrefix[1 : len(discoveryPrefix)-1]
|
|
}
|
|
|
|
cfg.Broker = brokerURL
|
|
cfg.Username = username
|
|
cfg.TLS = tlsEnabled != 0
|
|
cfg.DiscoveryPrefix = discoveryPrefix
|
|
|
|
// Set connection status and mothership ID
|
|
h.mu.RLock()
|
|
if h.mqttClient != nil {
|
|
cfg.Connected = h.mqttClient.IsConnected()
|
|
cfg.MothershipID = h.mqttClient.GetMothershipID()
|
|
} else {
|
|
cfg.MothershipID = h.mothershipID
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// updateMQTTSettings updates MQTT configuration.
|
|
func (h *IntegrationSettingsHandler) updateMQTTSettings(cfg *mqttConfig) error {
|
|
// Validate broker URL format
|
|
if cfg.Broker != "" {
|
|
if len(cfg.Broker) < 6 || (cfg.Broker[:3] != "tcp" && cfg.Broker[:4] != "mqtt" && cfg.Broker[:5] != "mqtts") {
|
|
return &validationError{Field: "broker", Reason: "invalid URL format (must start with tcp://, mqtt://, or mqtts://)"}
|
|
}
|
|
}
|
|
|
|
// Save broker URL
|
|
if cfg.Broker != "" {
|
|
brokerJSON, _ := json.Marshal(cfg.Broker)
|
|
_, err := h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"mqtt_broker", string(brokerJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Save username
|
|
if cfg.Username != "" {
|
|
usernameJSON, _ := json.Marshal(cfg.Username)
|
|
_, err := h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"mqtt_username", string(usernameJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Save password (if provided)
|
|
if cfg.Password != "" {
|
|
passwordJSON, _ := json.Marshal(cfg.Password)
|
|
_, err := h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"mqtt_password", string(passwordJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Save TLS setting
|
|
tlsJSON, _ := json.Marshal(cfg.TLS)
|
|
_, err := h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"mqtt_tls", string(tlsJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Save discovery prefix
|
|
if cfg.DiscoveryPrefix != "" {
|
|
prefixJSON, _ := json.Marshal(cfg.DiscoveryPrefix)
|
|
_, err := h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"mqtt_discovery_prefix", string(prefixJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update MQTT client if available
|
|
h.mu.RLock()
|
|
client := h.mqttClient
|
|
h.mu.RUnlock()
|
|
|
|
if client != nil {
|
|
// Import mqtt package's Config type
|
|
mqttCfg := map[string]interface{}{
|
|
"broker": cfg.Broker,
|
|
"username": cfg.Username,
|
|
"password": cfg.Password,
|
|
"tls": cfg.TLS,
|
|
"discovery_prefix": cfg.DiscoveryPrefix,
|
|
"mothership_id": h.mothershipID,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := client.UpdateConfig(ctx, mqttCfg); err != nil {
|
|
log.Printf("[WARN] Failed to update MQTT client config: %v", err)
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getWebhookConfig retrieves system webhook settings from the database.
|
|
func (h *IntegrationSettingsHandler) getWebhookConfig() (*webhookConfig, error) {
|
|
var cfg webhookConfig
|
|
|
|
// Query from settings table
|
|
var valueJSON string
|
|
err := h.db.QueryRow(`SELECT value_json FROM settings WHERE key = 'system_webhook'`).Scan(&valueJSON)
|
|
if err != nil {
|
|
// Return default config if not found
|
|
return &webhookConfig{Enabled: false}, nil
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(valueJSON), &cfg); err != nil {
|
|
return &webhookConfig{Enabled: false}, nil
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// updateWebhookSettings updates system webhook configuration.
|
|
func (h *IntegrationSettingsHandler) updateWebhookSettings(cfg *webhookConfig) error {
|
|
// Validate URL if enabled
|
|
if cfg.Enabled && cfg.URL == "" {
|
|
return &validationError{Field: "url", Reason: "URL is required when webhook is enabled"}
|
|
}
|
|
|
|
// Validate URL format
|
|
if cfg.URL != "" {
|
|
if cfg.URL[0] != '/' && (cfg.URL[:4] != "http" && cfg.URL[:5] != "https") {
|
|
return &validationError{Field: "url", Reason: "invalid URL format (must start with http:// or https://)"}
|
|
}
|
|
}
|
|
|
|
// Save to settings table
|
|
valueJSON, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = h.db.Exec(`INSERT OR REPLACE INTO settings (key, value_json, updated_at) VALUES (?, ?, ?)`,
|
|
"system_webhook", string(valueJSON), "strftime('%s', 'now')")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update webhook publisher if available
|
|
if h.webhookPublisher != nil {
|
|
// Import webhook package's Config type
|
|
webhookCfg := map[string]interface{}{
|
|
"url": cfg.URL,
|
|
"enabled": cfg.Enabled,
|
|
}
|
|
h.webhookPublisher.UpdateConfig(webhookCfg)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// testMQTT tests MQTT connection by publishing discovery messages.
|
|
func (h *IntegrationSettingsHandler) testMQTT(w http.ResponseWriter, r *http.Request) {
|
|
if h.mqttClient == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "MQTT client not configured")
|
|
return
|
|
}
|
|
|
|
if !h.mqttClient.IsConnected() {
|
|
// Try to reconnect
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := h.mqttClient.Reconnect(ctx); err != nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "MQTT connection failed: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Publish discovery messages as a test
|
|
_, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Publish discovery now
|
|
if err := h.mqttClient.PublishDiscoveryNow(); err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to publish discovery: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"message": "MQTT connection verified. Discovery messages published.",
|
|
})
|
|
}
|
|
|
|
// testWebhook tests webhook by sending a test event.
|
|
func (h *IntegrationSettingsHandler) testWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if h.webhookPublisher == nil {
|
|
writeJSONError(w, http.StatusServiceUnavailable, "Webhook publisher not configured")
|
|
return
|
|
}
|
|
|
|
if err := h.webhookPublisher.TestWebhook(); err != nil {
|
|
if ve, ok := err.(*validationError); ok {
|
|
writeJSONError(w, http.StatusBadRequest, ve.Error())
|
|
} else {
|
|
writeJSONError(w, http.StatusInternalServerError, "Webhook test failed: "+err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "success",
|
|
"message": "Test webhook event sent successfully",
|
|
})
|
|
}
|
|
|
|
// validationError represents a settings validation error.
|
|
type validationError struct {
|
|
Field string
|
|
Reason string
|
|
}
|
|
|
|
func (e *validationError) Error() string {
|
|
return e.Field + ": " + e.Reason
|
|
}
|