Implements comprehensive anomaly detection system that learns normal household patterns over 7+ days and alerts on deviations. Transforms spaxel into a basic home security system. Core features: - Normal behaviour model: statistical tracking of occupancy patterns per (hour_of_week, zone_id) slot with expected occupancy, typical person count, and typical BLE devices - Four anomaly types: unusual hour presence, unknown BLE device, motion during away mode, unusual dwell duration - Security mode: lowered thresholds, immediate alerts, bypasses quiet hours - Auto-away/disarm: automatic security mode activation based on BLE device presence (15min absence, auto-disarm on device return) - Alert chain: staged notifications (dashboard → push → webhook → escalation) - WebSocket integration: real-time anomaly broadcasts to dashboard API endpoints: - GET/POST /api/mode: system mode control (home/away/sleep) - GET /api/security/status: current security state Tests cover all anomaly types, alert chain timing, security mode thresholds, auto-away/disarm, acknowledgement flow, and cooldown deduplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
8 KiB
Go
274 lines
8 KiB
Go
// Package api provides REST API handlers for Spaxel security mode.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/spaxel/mothership/internal/analytics"
|
|
"github.com/spaxel/mothership/internal/events"
|
|
)
|
|
|
|
// SecurityHandler manages security mode state and API endpoints.
|
|
type SecurityHandler struct {
|
|
detector DetectorProvider
|
|
}
|
|
|
|
// DetectorProvider is an interface to access the anomaly detector.
|
|
type DetectorProvider interface {
|
|
GetSecurityMode() analytics.SecurityMode
|
|
SetSecurityMode(mode analytics.SecurityMode, reason string)
|
|
IsSecurityModeActive() bool
|
|
GetLearningProgress() float64
|
|
IsModelReady() bool
|
|
GetActiveAnomalies() []*events.AnomalyEvent
|
|
CountAnomaliesSince(since time.Time) (int, error)
|
|
GetSystemMode() events.SystemMode
|
|
}
|
|
|
|
// NewSecurityHandler creates a new security handler.
|
|
func NewSecurityHandler(detector DetectorProvider) *SecurityHandler {
|
|
return &SecurityHandler{
|
|
detector: detector,
|
|
}
|
|
}
|
|
|
|
// RegisterRoutes registers security API routes on the given router.
|
|
func (h *SecurityHandler) RegisterRoutes(r chi.Router) {
|
|
r.Post("/api/security/arm", h.handleArm)
|
|
r.Post("/api/security/disarm", h.handleDisarm)
|
|
r.Get("/api/security/status", h.handleStatus)
|
|
r.Get("/api/mode", h.handleGetMode)
|
|
r.Post("/api/mode", h.handleSetMode)
|
|
}
|
|
|
|
// SecurityStatus represents the current security mode state.
|
|
type SecurityStatus struct {
|
|
Armed bool `json:"armed"`
|
|
Mode string `json:"mode,omitempty"` // "armed", "armed_stay", or "disarmed"
|
|
LearningUntil string `json:"learning_until,omitempty"` // ISO8601 when model will be ready, empty if ready
|
|
AnomalyCount24h int `json:"anomaly_count_24h"`
|
|
ModelReady bool `json:"model_ready"`
|
|
}
|
|
|
|
// SystemModeResponse represents the current system mode response.
|
|
type SystemModeResponse struct {
|
|
Mode string `json:"mode"` // "home", "away", "sleep"
|
|
Armed bool `json:"armed"`
|
|
LearningUntil string `json:"learning_until,omitempty"`
|
|
AnomalyCount24h int `json:"anomaly_count_24h"`
|
|
ModelReady bool `json:"model_ready"`
|
|
LastChange string `json:"last_change,omitempty"`
|
|
LastChangeBy string `json:"last_change_by,omitempty"`
|
|
}
|
|
|
|
// handleStatus returns the current security mode status.
|
|
// Response JSON:
|
|
// {
|
|
// "armed": true,
|
|
// "mode": "armed",
|
|
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
|
// "anomaly_count_24h": 5,
|
|
// "model_ready": false
|
|
// }
|
|
func (h *SecurityHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
if h.detector == nil {
|
|
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
mode := h.detector.GetSecurityMode()
|
|
armed := h.detector.IsSecurityModeActive()
|
|
modelReady := h.detector.IsModelReady()
|
|
progress := h.detector.GetLearningProgress()
|
|
|
|
status := SecurityStatus{
|
|
Armed: armed,
|
|
Mode: string(mode),
|
|
ModelReady: modelReady,
|
|
AnomalyCount24h: h.countAnomalies24h(),
|
|
}
|
|
|
|
// Calculate learning_until if model is not ready
|
|
if !modelReady {
|
|
// Get the learning start time by calculating from progress
|
|
// progress = elapsed / (7 days)
|
|
// elapsed = progress * 7 days
|
|
// learning_until = start + 7 days = now + (7 days - elapsed)
|
|
elapsed := time.Duration(float64(7*24*time.Hour) * progress)
|
|
remaining := 7*24*time.Hour - elapsed
|
|
learningUntil := time.Now().Add(remaining)
|
|
status.LearningUntil = learningUntil.Format(time.RFC3339)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
// handleArm enables security mode.
|
|
// Request body (optional): {"mode": "armed"} or {"mode": "armed_stay"}
|
|
// Default mode is "armed" if not specified.
|
|
// Response: {"armed": true, "mode": "armed"}
|
|
func (h *SecurityHandler) handleArm(w http.ResponseWriter, r *http.Request) {
|
|
if h.detector == nil {
|
|
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Mode string `json:"mode"` // "armed" or "armed_stay"
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var mode analytics.SecurityMode
|
|
switch req.Mode {
|
|
case "armed_stay":
|
|
mode = analytics.SecurityModeArmedStay
|
|
case "armed", "":
|
|
mode = analytics.SecurityModeArmed
|
|
default:
|
|
http.Error(w, "invalid mode: must be 'armed' or 'armed_stay'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
h.detector.SetSecurityMode(mode, "api")
|
|
|
|
status := map[string]interface{}{
|
|
"armed": true,
|
|
"mode": string(mode),
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
// handleDisarm disables security mode.
|
|
// Response: {"armed": false, "mode": "disarmed"}
|
|
func (h *SecurityHandler) handleDisarm(w http.ResponseWriter, r *http.Request) {
|
|
if h.detector == nil {
|
|
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
h.detector.SetSecurityMode(analytics.SecurityModeDisarmed, "api")
|
|
|
|
status := map[string]interface{}{
|
|
"armed": false,
|
|
"mode": "disarmed",
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
// countAnomalies24h counts anomalies detected in the last 24 hours.
|
|
func (h *SecurityHandler) countAnomalies24h() int {
|
|
if h.detector == nil {
|
|
return 0
|
|
}
|
|
|
|
count, err := h.detector.CountAnomaliesSince(time.Now().Add(-24 * time.Hour))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return count
|
|
}
|
|
|
|
// handleGetMode returns the current system mode (home/away/sleep).
|
|
// Response JSON:
|
|
// {
|
|
// "mode": "home",
|
|
// "armed": false,
|
|
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
|
// "anomaly_count_24h": 5,
|
|
// "model_ready": false,
|
|
// "last_change": "2024-04-15T10:30:00Z",
|
|
// "last_change_by": "auto_away"
|
|
// }
|
|
func (h *SecurityHandler) handleGetMode(w http.ResponseWriter, r *http.Request) {
|
|
if h.detector == nil {
|
|
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
mode := h.detector.GetSystemMode()
|
|
armed := h.detector.IsSecurityModeActive()
|
|
modelReady := h.detector.IsModelReady()
|
|
progress := h.detector.GetLearningProgress()
|
|
|
|
response := SystemModeResponse{
|
|
Mode: string(mode),
|
|
Armed: armed,
|
|
ModelReady: modelReady,
|
|
AnomalyCount24h: h.countAnomalies24h(),
|
|
}
|
|
|
|
// Calculate learning_until if model is not ready
|
|
if !modelReady {
|
|
elapsed := time.Duration(float64(7*24*time.Hour) * progress)
|
|
remaining := 7*24*time.Hour - elapsed
|
|
learningUntil := time.Now().Add(remaining)
|
|
response.LearningUntil = learningUntil.Format(time.RFC3339)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleSetMode sets the system mode (home/away/sleep).
|
|
// Request body:
|
|
// {
|
|
// "mode": "away", // "home", "away", or "sleep"
|
|
// "reason": "manual" // optional reason for logging
|
|
// }
|
|
// Response: SystemModeResponse
|
|
func (h *SecurityHandler) handleSetMode(w http.ResponseWriter, r *http.Request) {
|
|
if h.detector == nil {
|
|
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Mode string `json:"mode"` // "home", "away", or "sleep"
|
|
Reason string `json:"reason"` // optional reason
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Default reason
|
|
if req.Reason == "" {
|
|
req.Reason = "api"
|
|
}
|
|
|
|
var securityMode analytics.SecurityMode
|
|
switch req.Mode {
|
|
case "away":
|
|
securityMode = analytics.SecurityModeArmed
|
|
case "sleep":
|
|
securityMode = analytics.SecurityModeArmedStay
|
|
case "home", "":
|
|
securityMode = analytics.SecurityModeDisarmed
|
|
default:
|
|
http.Error(w, "invalid mode: must be 'home', 'away', or 'sleep'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
h.detector.SetSecurityMode(securityMode, req.Reason)
|
|
|
|
// Return updated status
|
|
mode := h.detector.GetSystemMode()
|
|
armed := h.detector.IsSecurityModeActive()
|
|
modelReady := h.detector.IsModelReady()
|
|
|
|
response := SystemModeResponse{
|
|
Mode: string(mode),
|
|
Armed: armed,
|
|
ModelReady: modelReady,
|
|
AnomalyCount24h: h.countAnomalies24h(),
|
|
LastChange: time.Now().Format(time.RFC3339),
|
|
LastChangeBy: req.Reason,
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|