spaxel/mothership/internal/api/integrations.go
jedarden af64a30af6 feat: home automation integration (MQTT and webhooks)
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>
2026-04-11 06:29:51 -04:00

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
}