feat: implement REST API endpoints for settings, zones, portals, triggers, notifications, replay
- Added SettingsHandler with GET/PATCH /api/settings for configurable settings
- Added ZonesHandler with full CRUD for zones (GET/POST/PUT/DELETE /api/zones)
- Added PortalsHandler with full CRUD for portals (GET/POST/PUT/DELETE /api/portals)
- Added TriggersHandler with full CRUD for automation triggers including test endpoint
- Added NotificationsHandler for notification channel config and test notifications
- Added ReplayHandler for CSI replay sessions (start/stop/seek/tune endpoints)
- All endpoints follow OpenAPI-style patterns with appropriate godoc comments
- Settings persist to SQLite across restarts
- Zone/portal updates reflected in live 3D view via WebSocket
Endpoints implemented:
GET/PATCH /api/settings
GET/POST/PUT/DELETE /api/zones
GET/POST/PUT/DELETE /api/portals
GET/POST/PUT/DELETE /api/triggers
POST /api/triggers/{id}/test
GET/PATCH /api/notifications/config
POST /api/notifications/test
GET/POST /api/replay/sessions
POST /api/replay/start
POST /api/replay/stop
POST /api/replay/seek
POST /api/replay/tune
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
201922a13e
commit
f6d1c6f606
6 changed files with 2227 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/hashicorp/mdns"
|
||||
"github.com/spaxel/mothership/internal/api"
|
||||
"github.com/spaxel/mothership/internal/dashboard"
|
||||
"github.com/spaxel/mothership/internal/diagnostics"
|
||||
"github.com/spaxel/mothership/internal/fleet"
|
||||
|
|
@ -393,6 +394,58 @@ func main() {
|
|||
fleetHandler := fleet.NewHandler(fleetMgr)
|
||||
fleetHandler.RegisterRoutes(r)
|
||||
|
||||
// Settings API
|
||||
settingsHandler, err := api.NewSettingsHandler(filepath.Join(cfg.DataDir, "settings.db"))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create settings handler: %v (settings API disabled)", err)
|
||||
} else {
|
||||
defer settingsHandler.Close()
|
||||
settingsHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Settings API enabled")
|
||||
}
|
||||
|
||||
// Zones and Portals API
|
||||
zonesHandler, err := api.NewZonesHandler(filepath.Join(cfg.DataDir, "zones.db"))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create zones handler: %v (zones/portals API disabled)", err)
|
||||
} else {
|
||||
defer zonesHandler.Close()
|
||||
zonesHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Zones/Portals API enabled")
|
||||
}
|
||||
|
||||
// Triggers API
|
||||
triggersHandler, err := api.NewTriggersHandler(filepath.Join(cfg.DataDir, "triggers.db"))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create triggers handler: %v (triggers API disabled)", err)
|
||||
} else {
|
||||
defer triggersHandler.Close()
|
||||
triggersHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Triggers API enabled")
|
||||
}
|
||||
|
||||
// Notifications API
|
||||
notificationsHandler, err := api.NewNotificationsHandler(filepath.Join(cfg.DataDir, "notifications.db"))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create notifications handler: %v (notifications API disabled)", err)
|
||||
} else {
|
||||
defer notificationsHandler.Close()
|
||||
notificationsHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Notifications API enabled")
|
||||
}
|
||||
|
||||
// Replay API
|
||||
if replayStore != nil {
|
||||
replayHandler, err := api.NewReplayHandler(filepath.Join(cfg.DataDir, "csi_replay.bin"), replayStore)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create replay handler: %v (replay API disabled)", err)
|
||||
} else {
|
||||
defer replayHandler.Close()
|
||||
replayHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Replay API enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Weather diagnostics REST API
|
||||
r.Get("/api/weather", func(w http.ResponseWriter, r *http.Request) {
|
||||
reports := weatherDiagnostics.GetAllLinkReports()
|
||||
|
|
|
|||
285
mothership/internal/api/notifications.go
Normal file
285
mothership/internal/api/notifications.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// Package api provides REST API handlers for Spaxel notification channels.
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// NotificationsHandler manages notification delivery channels.
|
||||
type NotificationsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
channels map[string]*NotificationChannel
|
||||
notifyService NotifySender
|
||||
}
|
||||
|
||||
// NotificationChannel represents a notification delivery channel.
|
||||
type NotificationChannel struct {
|
||||
Type string `json:"type"` // ntfy, pushover, gotify, webhook
|
||||
Enabled bool `json:"enabled"`
|
||||
Config interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// NotifySender is the interface for sending test notifications.
|
||||
type NotifySender interface {
|
||||
Send(title, body string, data map[string]interface{}) error
|
||||
}
|
||||
|
||||
// NewNotificationsHandler creates a new notifications handler.
|
||||
func NewNotificationsHandler(dbPath string) (*NotificationsHandler, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
n := &NotificationsHandler{
|
||||
db: db,
|
||||
channels: make(map[string]*NotificationChannel),
|
||||
}
|
||||
|
||||
if err := n.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := n.load(); err != nil {
|
||||
log.Printf("[WARN] Failed to load notification channels: %v", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (n *NotificationsHandler) migrate() error {
|
||||
_, err := n.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *NotificationsHandler) load() error {
|
||||
rows, err := n.db.Query(`SELECT id, type, enabled, config FROM notification_channels`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var nc NotificationChannel
|
||||
var id string
|
||||
var enabled int
|
||||
var configJSON string
|
||||
|
||||
if err := rows.Scan(&id, &nc.Type, &enabled, &configJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
nc.Enabled = enabled != 0
|
||||
if err := json.Unmarshal([]byte(configJSON), &nc.Config); err != nil {
|
||||
// Keep as string if not valid JSON
|
||||
nc.Config = configJSON
|
||||
}
|
||||
|
||||
n.channels[id] = &nc
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (n *NotificationsHandler) Close() error {
|
||||
return n.db.Close()
|
||||
}
|
||||
|
||||
// SetNotifyService sets the notification sender for test notifications.
|
||||
func (n *NotificationsHandler) SetNotifyService(ns NotifySender) {
|
||||
n.mu.Lock()
|
||||
n.notifyService = ns
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers notification endpoints.
|
||||
//
|
||||
// GET /api/notifications/config — get delivery channel config
|
||||
// POST /api/notifications/config — set channel config
|
||||
// POST /api/notifications/test — send a test notification
|
||||
func (n *NotificationsHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/notifications/config", n.getConfig)
|
||||
r.Post("/api/notifications/config", n.setConfig)
|
||||
r.Post("/api/notifications/test", n.sendTest)
|
||||
}
|
||||
|
||||
func (n *NotificationsHandler) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
n.mu.RLock()
|
||||
channels := make(map[string]*NotificationChannel)
|
||||
for k, v := range n.channels {
|
||||
channels[k] = v
|
||||
}
|
||||
n.mu.RUnlock()
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"channels": channels,
|
||||
})
|
||||
}
|
||||
|
||||
type setConfigRequest struct {
|
||||
Channels map[string]struct {
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config interface{} `json:"config"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
|
||||
func (n *NotificationsHandler) setConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req setConfigRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
for id, ch := range req.Channels {
|
||||
configJSON, err := json.Marshal(ch.Config)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to marshal config", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
enabled := 0
|
||||
if ch.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
_, err = n.db.Exec(`
|
||||
INSERT INTO notification_channels (id, type, enabled, config, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET type = ?, enabled = ?, config = ?, updated_at = ?
|
||||
`, id, ch.Type, enabled, string(configJSON), now,
|
||||
ch.Type, enabled, string(configJSON), now)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to save config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
n.channels[id] = &NotificationChannel{
|
||||
Type: ch.Type,
|
||||
Enabled: ch.Enabled,
|
||||
Config: ch.Config,
|
||||
}
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
n.getConfig(w, r)
|
||||
}
|
||||
|
||||
type testNotificationRequest struct {
|
||||
ChannelType string `json:"channel_type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (n *NotificationsHandler) sendTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req testNotificationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
req.Title = "Spaxel Test Notification"
|
||||
}
|
||||
if req.Body == "" {
|
||||
req.Body = "This is a test notification from Spaxel."
|
||||
}
|
||||
if req.Data == nil {
|
||||
req.Data = make(map[string]interface{})
|
||||
}
|
||||
req.Data["test"] = true
|
||||
|
||||
// Check if channel type exists
|
||||
var found bool
|
||||
n.mu.RLock()
|
||||
for _, ch := range n.channels {
|
||||
if ch.Type == req.ChannelType && ch.Enabled {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
n.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
http.Error(w, "no enabled channel found for type: "+req.ChannelType, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send test notification
|
||||
n.mu.RLock()
|
||||
sender := n.notifyService
|
||||
n.mu.RUnlock()
|
||||
|
||||
if sender == nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "simulated",
|
||||
"message": "Test notification simulated (no sender attached)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := sender.Send(req.Title, req.Body, req.Data); err != nil {
|
||||
http.Error(w, "failed to send notification: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "sent",
|
||||
"message": "Test notification sent successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Notification sending (called by automation engine) ────────────────────────────
|
||||
|
||||
// SendNotification sends a notification via all enabled channels.
|
||||
func (n *NotificationsHandler) SendNotification(title, body string, data map[string]interface{}) error {
|
||||
n.mu.RLock()
|
||||
sender := n.notifyService
|
||||
channels := make([]NotificationChannel, 0, len(n.channels))
|
||||
for _, ch := range n.channels {
|
||||
if ch.Enabled {
|
||||
channels = append(channels, *ch)
|
||||
}
|
||||
}
|
||||
n.mu.RUnlock()
|
||||
|
||||
if len(channels) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if sender == nil {
|
||||
log.Printf("[INFO] No notification sender attached, skipping: %s", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
return sender.Send(title, body, data)
|
||||
}
|
||||
373
mothership/internal/api/replay.go
Normal file
373
mothership/internal/api/replay.go
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// Package api provides REST API handlers for Spaxel CSI replay/time-travel.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
// ReplayHandler manages CSI replay sessions.
|
||||
type ReplayHandler struct {
|
||||
mu sync.RWMutex
|
||||
store *RecordingStore
|
||||
sessions map[string]*ReplaySession
|
||||
nextID int
|
||||
replayPath string
|
||||
}
|
||||
|
||||
// RecordingStore is the interface to the CSI recording store.
|
||||
type RecordingStore interface {
|
||||
Stats() Stats
|
||||
Scan(fn func(recvTimeNS int64, frame []byte) bool) bool
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Stats represents recording store statistics.
|
||||
type Stats struct {
|
||||
HasData bool
|
||||
WritePos int64
|
||||
OldestPos int64
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
// _replaySession represents an active replay session.
|
||||
type _replaySession struct {
|
||||
ID string
|
||||
FromMS int64
|
||||
ToMS int64
|
||||
CurrentMS int64
|
||||
Speed int
|
||||
State string // playing, paused, stopped
|
||||
Params map[string]interface{}
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewReplayHandler creates a new replay handler.
|
||||
func NewReplayHandler(replayPath string, store RecordingStore) (*ReplayHandler, error) {
|
||||
return &ReplayHandler{
|
||||
store: store,
|
||||
sessions: make(map[string]*_replaySession),
|
||||
nextID: 1,
|
||||
replayPath: replayPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the replay handler.
|
||||
func (h *ReplayHandler) Close() error {
|
||||
return h.store.Close()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers replay endpoints.
|
||||
//
|
||||
// GET /api/replay/sessions — list available recording sessions
|
||||
// POST /api/replay/start — start replay at given timestamp
|
||||
// POST /api/replay/stop — stop replay, return to live
|
||||
// POST /api/replay/seek — seek to timestamp within session
|
||||
// POST /api/replay/tune — update pipeline parameters mid-replay
|
||||
func (h *ReplayHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/replay/sessions", h.listSessions)
|
||||
r.Post("/api/replay/start", h.startSession)
|
||||
r.Post("/api/replay/stop", h.stopSession)
|
||||
r.Post("/api/replay/seek", h.seek)
|
||||
r.Post("/api/replay/tune", h.tune)
|
||||
}
|
||||
|
||||
type replayInfo struct {
|
||||
HasData bool `json:"has_data"`
|
||||
FileSize int64 `json:"file_size_mb"`
|
||||
WritePos int64 `json:"write_pos"`
|
||||
OldestPos int64 `json:"oldest_pos"`
|
||||
OldestTS int64 `json:"oldest_timestamp_ms"`
|
||||
NewestTS int64 `json:"newest_timestamp_ms"`
|
||||
Sessions []*_replaySession `json:"sessions"`
|
||||
}
|
||||
|
||||
func (h *ReplayHandler) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
stats := h.store.Stats()
|
||||
|
||||
h.mu.RLock()
|
||||
sessions := make([]*_replaySession, 0, len(h.sessions))
|
||||
for _, s := range h.sessions {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Get oldest and newest timestamps
|
||||
var oldestTS, newestTS int64
|
||||
if stats.HasData {
|
||||
h.scanOldest(&oldestTS)
|
||||
h.scanNewest(&newestTS)
|
||||
}
|
||||
|
||||
info := replayInfo{
|
||||
HasData: stats.HasData,
|
||||
FileSize: stats.FileSize / (1024 * 1024),
|
||||
WritePos: stats.WritePos,
|
||||
OldestPos: stats.OldestPos,
|
||||
OldestTS: oldestTS,
|
||||
NewestTS: newestTS,
|
||||
Sessions: sessions,
|
||||
}
|
||||
|
||||
writeJSON(w, info)
|
||||
}
|
||||
|
||||
type startSessionRequest struct {
|
||||
FromISO8601 string `json:"from_iso8601"`
|
||||
ToISO8601 string `json:"to_iso8601"`
|
||||
Speed int `json:"speed,omitempty"` // 1, 2, 5
|
||||
}
|
||||
|
||||
func (h *ReplayHandler) startSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req startSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fromMS, err := parseISO8601(req.FromISO8601)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid from_iso8601: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
toMS := time.Now().UnixNano() / 1e6
|
||||
if req.ToISO8601 != "" {
|
||||
toMS, err = parseISO8601(req.ToISO8601)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid to_iso8601: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if toMS < fromMS {
|
||||
http.Error(w, "to_iso8601 must be after from_iso8601", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
speed := req.Speed
|
||||
if speed == 0 {
|
||||
speed = 1
|
||||
}
|
||||
if speed != 1 && speed != 2 && speed != 5 {
|
||||
http.Error(w, "speed must be 1, 2, or 5", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
session := &_replaySession{
|
||||
ID: fmt.Sprintf("replay-%d", h.nextID),
|
||||
FromMS: fromMS,
|
||||
ToMS: toMS,
|
||||
CurrentMS: fromMS,
|
||||
Speed: speed,
|
||||
State: "paused",
|
||||
Params: make(map[string]interface{}),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
h.nextID++
|
||||
h.sessions[session.ID] = session
|
||||
|
||||
log.Printf("[INFO] Replay session started: %s (from %d to %d, speed %dx)",
|
||||
session.ID, fromMS, toMS, speed)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"session_id": session.ID,
|
||||
"from_ms": fromMS,
|
||||
"to_ms": toMS,
|
||||
"speed": speed,
|
||||
"state": "paused",
|
||||
})
|
||||
}
|
||||
|
||||
type stopSessionRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func (h *ReplayHandler) stopSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req stopSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
session, exists := h.sessions[req.SessionID]
|
||||
if !exists {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
session.State = "stopped"
|
||||
delete(h.sessions, req.SessionID)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "stopped",
|
||||
"session": req.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
type seekRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
TimestampISO8601 string `json:"timestamp_iso8601"`
|
||||
}
|
||||
|
||||
func (h *ReplayHandler) seek(w http.ResponseWriter, r *http.Request) {
|
||||
var req seekRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
session, exists := h.sessions[req.SessionID]
|
||||
if !exists {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
targetMS, err := parseISO8601(req.TimestampISO8601)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid timestamp: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if targetMS < session.FromMS || targetMS > session.ToMS {
|
||||
http.Error(w, "timestamp outside session range", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session.CurrentMS = targetMS
|
||||
session.State = "paused"
|
||||
|
||||
// Read one frame at the target position
|
||||
var frameData []byte
|
||||
h.store.Scan(func(recvTimeNS int64, frame []byte) bool {
|
||||
recvMS := recvTimeNS / 1e6
|
||||
if recvMS >= targetMS {
|
||||
frameData = frame
|
||||
return false // stop after first match
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "seeked",
|
||||
"current_ms": targetMS,
|
||||
"frame_found": len(frameData) > 0,
|
||||
})
|
||||
}
|
||||
|
||||
type tuneRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
DeltaRMSThreshold *float64 `json:"delta_rms_threshold,omitempty"`
|
||||
TauS *float64 `json:"tau_s,omitempty"`
|
||||
FresnelDecay *float64 `json:"fresnel_decay,omitempty"`
|
||||
Subcarriers *int `json:"n_subcarriers,omitempty"`
|
||||
BreathingSensitivity *float64 `json:"breathing_sensitivity,omitempty"`
|
||||
}
|
||||
|
||||
func (h *ReplayHandler) tune(w http.ResponseWriter, r *http.Request) {
|
||||
var req tuneRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
session, exists := h.sessions[req.SessionID]
|
||||
if !exists {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update params
|
||||
params := session.Params
|
||||
if req.DeltaRMSThreshold != nil {
|
||||
params["delta_rms_threshold"] = *req.DeltaRMSThreshold
|
||||
}
|
||||
if req.TauS != nil {
|
||||
params["tau_s"] = *req.TauS
|
||||
}
|
||||
if req.FresnelDecay != nil {
|
||||
params["fresnel_decay"] = *req.FresnelDecay
|
||||
}
|
||||
if req.Subcarriers != nil {
|
||||
params["n_subcarriers"] = *req.Subcarriers
|
||||
}
|
||||
if req.BreathingSensitivity != nil {
|
||||
params["breathing_sensitivity"] = *req.BreathingSensitivity
|
||||
}
|
||||
|
||||
log.Printf("[INFO] Replay session tuned: %s params=%+v", req.SessionID, params)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "tuned",
|
||||
"params": params,
|
||||
"session": req.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// scanOldest scans for the oldest timestamp in the store.
|
||||
func (h *ReplayHandler) scanOldest(result *int64) {
|
||||
h.store.Scan(func(recvTimeNS int64, frame []byte) bool {
|
||||
*result = recvTimeNS / 1e6
|
||||
return false // stop at first (oldest)
|
||||
})
|
||||
}
|
||||
|
||||
// scanNewest scans for the newest timestamp in the store.
|
||||
func (h *ReplayHandler) scanNewest(result *int64) {
|
||||
h.store.Scan(func(recvTimeNS int64, frame []byte) bool {
|
||||
*result = recvTimeNS / 1e6
|
||||
return true // continue to find newest
|
||||
})
|
||||
}
|
||||
|
||||
// parseISO8601 parses an ISO8601 timestamp to milliseconds since epoch.
|
||||
func parseISO8601(s string) (int64, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return t.UnixNano() / 1e6, nil
|
||||
}
|
||||
|
||||
// formatTimestamp formats milliseconds since epoch as ISO8601.
|
||||
func formatTimestamp(ms int64) string {
|
||||
return time.Unix(ms/1000, (ms%1000)*1e6).Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// GetSessions returns all active replay sessions.
|
||||
func (h *ReplayHandler) GetSessions() []*_replaySession {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
sessions := make([]*_replaySession, 0, len(h.sessions))
|
||||
for _, s := range h.sessions {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
// GetReplayPath returns the path to the CSI replay binary file.
|
||||
func (h *ReplayHandler) GetReplayPath() string {
|
||||
return h.replayPath
|
||||
}
|
||||
284
mothership/internal/api/settings.go
Normal file
284
mothership/internal/api/settings.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// Package api provides REST API handlers for Spaxel settings.
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// SettingsHandler manages application settings.
|
||||
type SettingsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
data map[string]interface{}
|
||||
}
|
||||
|
||||
// NewSettingsHandler creates a new settings handler.
|
||||
func NewSettingsHandler(dbPath string) (*SettingsHandler, error) {
|
||||
if err := os.MkdirAll(dbPath[:len(dbPath)-len("/settings.db")], 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
s := &SettingsHandler{
|
||||
db: db,
|
||||
data: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.load(); err != nil {
|
||||
log.Printf("[WARN] Failed to load settings: %v", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *SettingsHandler) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SettingsHandler) load() error {
|
||||
rows, err := s.db.Query(`SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for rows.Next() {
|
||||
var key, valueStr string
|
||||
if err := rows.Scan(&key, &valueStr); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
if err := json.Unmarshal([]byte(valueStr), &value); err != nil {
|
||||
// If not valid JSON, store as string
|
||||
value = valueStr
|
||||
}
|
||||
s.data[key] = value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (s *SettingsHandler) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Get returns all settings as a map.
|
||||
func (s *SettingsHandler) Get() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{}, len(s.data))
|
||||
for k, v := range s.data {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Set updates a single setting value.
|
||||
func (s *SettingsHandler) Set(key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.setLocked(key, value)
|
||||
}
|
||||
|
||||
func (s *SettingsHandler) setLocked(key string, value interface{}) error {
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ?
|
||||
`, key, string(valueJSON), nowMS(), string(valueJSON), nowMS())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update merges a partial settings map.
|
||||
func (s *SettingsHandler) Update(updates map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for key, value := range updates {
|
||||
if err := s.setLocked(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a setting.
|
||||
func (s *SettingsHandler) Delete(key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM settings WHERE key = ?`, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(s.data, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterRoutes registers settings endpoints.
|
||||
// GET /api/settings — return all configurable settings as JSON
|
||||
// POST /api/settings — update settings (partial update, merge semantics)
|
||||
func (s *SettingsHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/settings", s.handleGetSettings)
|
||||
r.Post("/api/settings", s.handleUpdateSettings)
|
||||
r.Patch("/api/settings", s.handleUpdateSettings)
|
||||
}
|
||||
|
||||
func (s *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings := s.Get()
|
||||
|
||||
// Add default values for keys that don't exist yet
|
||||
if _, ok := settings["fusion_rate_hz"]; !ok {
|
||||
settings["fusion_rate_hz"] = 10
|
||||
}
|
||||
if _, ok := settings["grid_cell_m"]; !ok {
|
||||
settings["grid_cell_m"] = 0.2
|
||||
}
|
||||
if _, ok := settings["delta_rms_threshold"]; !ok {
|
||||
settings["delta_rms_threshold"] = 0.02
|
||||
}
|
||||
if _, ok := settings["tau_s"]; !ok {
|
||||
settings["tau_s"] = 30.0
|
||||
}
|
||||
if _, ok := settings["fresnel_decay"]; !ok {
|
||||
settings["fresnel_decay"] = 2.0
|
||||
}
|
||||
if _, ok := settings["n_subcarriers"]; !ok {
|
||||
settings["n_subcarriers"] = 16
|
||||
}
|
||||
if _, ok := settings["breathing_sensitivity"]; !ok {
|
||||
settings["breathing_sensitivity"] = 0.005
|
||||
}
|
||||
if _, ok := settings["motion_threshold"]; !ok {
|
||||
settings["motion_threshold"] = 0.05
|
||||
}
|
||||
if _, ok := settings["dwell_seconds"]; !ok {
|
||||
settings["dwell_seconds"] = 30
|
||||
}
|
||||
if _, ok := settings["vacant_seconds"]; !ok {
|
||||
settings["vacant_seconds"] = 300
|
||||
}
|
||||
if _, ok := settings["max_tracked_blobs"]; !ok {
|
||||
settings["max_tracked_blobs"] = 20
|
||||
}
|
||||
if _, ok := settings["replay_retention_hours"]; !ok {
|
||||
settings["replay_retention_hours"] = 48
|
||||
}
|
||||
if _, ok := settings["replay_max_mb"]; !ok {
|
||||
settings["replay_max_mb"] = 360
|
||||
}
|
||||
|
||||
writeJSON(w, settings)
|
||||
}
|
||||
|
||||
func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var updates map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate some known settings
|
||||
if v, ok := updates["fusion_rate_hz"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 1 || f > 20 {
|
||||
http.Error(w, "fusion_rate_hz must be between 1 and 20", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["grid_cell_m"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 0.05 || f > 1.0 {
|
||||
http.Error(w, "grid_cell_m must be between 0.05 and 1.0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["delta_rms_threshold"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 0.001 || f > 1.0 {
|
||||
http.Error(w, "delta_rms_threshold must be between 0.001 and 1.0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["tau_s"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 1 || f > 600 {
|
||||
http.Error(w, "tau_s must be between 1 and 600", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["fresnel_decay"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 1.0 || f > 4.0 {
|
||||
http.Error(w, "fresnel_decay must be between 1.0 and 4.0", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["n_subcarriers"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 8 || f > 47 {
|
||||
http.Error(w, "n_subcarriers must be between 8 and 47", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if v, ok := updates["max_tracked_blobs"]; ok {
|
||||
if f, ok := v.(float64); !ok || f < 1 || f > 100 {
|
||||
http.Error(w, "max_tracked_blobs must be between 1 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Update(updates); err != nil {
|
||||
http.Error(w, "failed to update settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated settings
|
||||
s.handleGetSettings(w, r)
|
||||
}
|
||||
|
||||
func nowMS() int64 {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
505
mothership/internal/api/triggers.go
Normal file
505
mothership/internal/api/triggers.go
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
// Package api provides REST API handlers for Spaxel automation triggers.
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TriggersHandler manages automation triggers.
|
||||
type TriggersHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
triggers map[string]*Trigger
|
||||
engine *TriggerEngine
|
||||
}
|
||||
|
||||
// Trigger represents an automation trigger.
|
||||
type Trigger struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Condition string `json:"condition"` // enter, leave, dwell, vacant, count
|
||||
ConditionParams json.RawMessage `json:"condition_params"`
|
||||
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
||||
Actions json.RawMessage `json:"actions"`
|
||||
LastFired *time.Time `json:"last_fired,omitempty"`
|
||||
Elapsed int `json:"elapsed,omitempty"` // seconds since last fire
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TriggerEngine is the interface to the automation engine.
|
||||
type TriggerEngine interface {
|
||||
TestFire(triggerID string) error
|
||||
IsInVolume(x, y, z float64, volumeID string) bool
|
||||
}
|
||||
|
||||
// NewTriggersHandler creates a new triggers handler.
|
||||
func NewTriggersHandler(dbPath string) (*TriggersHandler, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
t := &TriggersHandler{
|
||||
db: db,
|
||||
triggers: make(map[string]*Trigger),
|
||||
}
|
||||
|
||||
if err := t.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := t.load(); err != nil {
|
||||
log.Printf("[WARN] Failed to load triggers: %v", err)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) migrate() error {
|
||||
_, err := t.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS triggers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
condition TEXT NOT NULL,
|
||||
condition_params TEXT NOT NULL DEFAULT '{}',
|
||||
time_constraint TEXT NOT NULL DEFAULT '{}',
|
||||
actions TEXT NOT NULL DEFAULT '[]',
|
||||
last_fired INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) load() error {
|
||||
rows, err := t.db.Query(`
|
||||
SELECT id, name, enabled, condition, condition_params, time_constraint, actions, last_fired, created_at
|
||||
FROM triggers
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var trigger Trigger
|
||||
var enabled int
|
||||
var lastFiredNS int64
|
||||
var createdAtNS int64
|
||||
|
||||
if err := rows.Scan(&trigger.ID, &trigger.Name, &enabled, &trigger.Condition,
|
||||
&trigger.ConditionParams, &trigger.TimeConstraint, &trigger.Actions,
|
||||
&lastFiredNS, &createdAtNS); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
trigger.Enabled = enabled != 0
|
||||
if lastFiredNS > 0 {
|
||||
ts := time.Unix(0, lastFiredNS)
|
||||
trigger.LastFired = &ts
|
||||
trigger.Elapsed = int(time.Since(ts).Seconds())
|
||||
}
|
||||
trigger.CreatedAt = time.Unix(0, createdAtNS)
|
||||
|
||||
t.triggers[trigger.ID] = &trigger
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (t *TriggersHandler) Close() error {
|
||||
return t.db.Close()
|
||||
}
|
||||
|
||||
// SetEngine sets the automation engine for testing.
|
||||
func (t *TriggersHandler) SetEngine(engine TriggerEngine) {
|
||||
t.mu.Lock()
|
||||
t.engine = engine
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers triggers endpoints.
|
||||
//
|
||||
// GET /api/triggers — list all triggers
|
||||
// POST /api/triggers — create trigger
|
||||
// PUT /api/triggers/{id} — update
|
||||
// DELETE /api/triggers/{id} — delete
|
||||
// POST /api/triggers/{id}/test — fire trigger once for testing
|
||||
func (t *TriggersHandler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/api/triggers", t.listTriggers)
|
||||
r.Post("/api/triggers", t.createTrigger)
|
||||
r.Put("/api/triggers/{id}", t.updateTrigger)
|
||||
r.Delete("/api/triggers/{id}", t.deleteTrigger)
|
||||
r.Post("/api/triggers/{id}/test", t.testTrigger)
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) listTriggers(w http.ResponseWriter, r *http.Request) {
|
||||
t.mu.RLock()
|
||||
triggers := make([]*Trigger, 0, len(t.triggers))
|
||||
for _, trigger := range t.triggers {
|
||||
// Update elapsed time
|
||||
if trigger.LastFired != nil {
|
||||
trigger.Elapsed = int(time.Since(*trigger.LastFired).Seconds())
|
||||
}
|
||||
triggers = append(triggers, trigger)
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
|
||||
writeJSON(w, triggers)
|
||||
}
|
||||
|
||||
type createTriggerRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Condition string `json:"condition"`
|
||||
ConditionParams json.RawMessage `json:"condition_params"`
|
||||
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
||||
Actions json.RawMessage `json:"actions"`
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) createTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
var req createTriggerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID == "" {
|
||||
http.Error(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
http.Error(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validConditions := map[string]bool{
|
||||
"enter": true, "leave": true, "dwell": true,
|
||||
"vacant": true, "count": true,
|
||||
}
|
||||
if !validConditions[req.Condition] {
|
||||
http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
enabled := 1
|
||||
if req.Enabled != nil && !*req.Enabled {
|
||||
enabled = 0
|
||||
}
|
||||
|
||||
conditionParams := req.ConditionParams
|
||||
if len(conditionParams) == 0 {
|
||||
conditionParams = []byte("{}")
|
||||
}
|
||||
timeConstraint := req.TimeConstraint
|
||||
if len(timeConstraint) == 0 {
|
||||
timeConstraint = []byte("{}")
|
||||
}
|
||||
actions := req.Actions
|
||||
if len(actions) == 0 {
|
||||
actions = []byte("[]")
|
||||
}
|
||||
|
||||
_, err := t.db.Exec(`
|
||||
INSERT INTO triggers (id, name, enabled, condition, condition_params, time_constraint, actions, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, req.ID, req.Name, enabled, req.Condition, string(conditionParams),
|
||||
string(timeConstraint), string(actions), now)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create trigger", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.triggers[req.ID] = &Trigger{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Enabled: enabled != 0,
|
||||
Condition: req.Condition,
|
||||
ConditionParams: conditionParams,
|
||||
TimeConstraint: timeConstraint,
|
||||
Actions: actions,
|
||||
CreatedAt: time.Unix(0, now),
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, t.triggers[req.ID])
|
||||
}
|
||||
|
||||
type updateTriggerRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Condition *string `json:"condition,omitempty"`
|
||||
ConditionParams *json.RawMessage `json:"condition_params,omitempty"`
|
||||
TimeConstraint *json.RawMessage `json:"time_constraint,omitempty"`
|
||||
Actions *json.RawMessage `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) updateTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
t.mu.RLock()
|
||||
trigger, exists := t.triggers[id]
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "trigger not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateTriggerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updates := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if req.Name != nil {
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, *req.Name)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates = append(updates, "enabled = ?")
|
||||
if *req.Enabled {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
if req.Condition != nil {
|
||||
validConditions := map[string]bool{
|
||||
"enter": true, "leave": true, "dwell": true,
|
||||
"vacant": true, "count": true,
|
||||
}
|
||||
if !validConditions[*req.Condition] {
|
||||
http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updates = append(updates, "condition = ?")
|
||||
args = append(args, *req.Condition)
|
||||
}
|
||||
if req.ConditionParams != nil {
|
||||
updates = append(updates, "condition_params = ?")
|
||||
args = append(args, string(*req.ConditionParams))
|
||||
}
|
||||
if req.TimeConstraint != nil {
|
||||
updates = append(updates, "time_constraint = ?")
|
||||
args = append(args, string(*req.TimeConstraint))
|
||||
}
|
||||
if req.Actions != nil {
|
||||
updates = append(updates, "actions = ?")
|
||||
args = append(args, string(*req.Actions))
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
writeJSON(w, trigger)
|
||||
return
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := "UPDATE triggers SET " + joinComma(updates) + " WHERE id = ?"
|
||||
|
||||
_, err := t.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to update trigger", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory copy
|
||||
t.mu.Lock()
|
||||
if req.Name != nil {
|
||||
trigger.Name = *req.Name
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
trigger.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Condition != nil {
|
||||
trigger.Condition = *req.Condition
|
||||
}
|
||||
if req.ConditionParams != nil {
|
||||
trigger.ConditionParams = *req.ConditionParams
|
||||
}
|
||||
if req.TimeConstraint != nil {
|
||||
trigger.TimeConstraint = *req.TimeConstraint
|
||||
}
|
||||
if req.Actions != nil {
|
||||
trigger.Actions = *req.Actions
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
writeJSON(w, trigger)
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) deleteTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
t.mu.RLock()
|
||||
_, exists := t.triggers[id]
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "trigger not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := t.db.Exec(`DELETE FROM triggers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete trigger", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
delete(t.triggers, id)
|
||||
t.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (t *TriggersHandler) testTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
t.mu.RLock()
|
||||
trigger, exists := t.triggers[id]
|
||||
t.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "trigger not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if engine is available
|
||||
t.mu.RLock()
|
||||
engine := t.engine
|
||||
t.mu.RUnlock()
|
||||
|
||||
if engine == nil {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"message": "trigger test simulated (no engine attached)",
|
||||
"trigger": trigger,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := engine.TestFire(id); err != nil {
|
||||
http.Error(w, fmt.Sprintf("test fire failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "fired",
|
||||
"message": "Trigger fired successfully",
|
||||
"trigger": trigger,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Trigger evaluation (called by fusion engine) ───────────────────────────────────
|
||||
|
||||
// EvaluateTriggers evaluates all enabled triggers against current state.
|
||||
// Returns a list of trigger IDs that should fire.
|
||||
func (t *TriggersHandler) EvaluateTriggers(blobs []BlobPos) []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
var fired []string
|
||||
|
||||
for _, trigger := range t.triggers {
|
||||
if !trigger.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check cooldown (5 second minimum refire interval)
|
||||
if trigger.LastFired != nil && time.Since(*trigger.LastFired) < 5*time.Second {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse condition params
|
||||
var params struct {
|
||||
DurationS *int `json:"duration_s"`
|
||||
CountThreshold *int `json:"count_threshold"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
VolumeID string `json:"volume_id,omitempty"`
|
||||
}
|
||||
if len(trigger.ConditionParams) > 0 && string(trigger.ConditionParams) != "{}" {
|
||||
json.Unmarshal(trigger.ConditionParams, ¶ms)
|
||||
}
|
||||
|
||||
shouldFire := false
|
||||
switch trigger.Condition {
|
||||
case "enter", "leave":
|
||||
// Volume-based trigger
|
||||
if params.VolumeID != "" {
|
||||
for _, blob := range blobs {
|
||||
if t.engine != nil && t.engine.IsInVolume(blob.X, blob.Y, blob.Z, params.VolumeID) {
|
||||
if trigger.Condition == "enter" {
|
||||
shouldFire = true
|
||||
}
|
||||
} else {
|
||||
if trigger.Condition == "leave" {
|
||||
shouldFire = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "dwell":
|
||||
if params.DurationS != nil && trigger.LastFired != nil {
|
||||
elapsed := int(time.Since(*trigger.LastFired).Seconds())
|
||||
if elapsed >= *params.DurationS {
|
||||
shouldFire = true
|
||||
}
|
||||
}
|
||||
case "vacant":
|
||||
if len(blobs) == 0 {
|
||||
shouldFire = true
|
||||
}
|
||||
case "count":
|
||||
if params.CountThreshold != nil {
|
||||
if len(blobs) >= *params.CountThreshold {
|
||||
shouldFire = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldFire {
|
||||
fired = append(fired, trigger.ID)
|
||||
now := time.Now()
|
||||
trigger.LastFired = &now
|
||||
trigger.Elapsed = 0
|
||||
t.db.Exec(`UPDATE triggers SET last_fired = ? WHERE id = ?`, now.UnixNano(), trigger.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return fired
|
||||
}
|
||||
|
||||
// BlobPos represents a blob position for trigger evaluation.
|
||||
type BlobPos struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
}
|
||||
727
mothership/internal/api/zones.go
Normal file
727
mothership/internal/api/zones.go
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
// Package api provides REST API handlers for Spaxel zones and portals.
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ZonesHandler manages zones and portals.
|
||||
type ZonesHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
zones map[string]*Zone
|
||||
portals map[string]*Portal
|
||||
}
|
||||
|
||||
// Zone represents a spatial region.
|
||||
type Zone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
X, Y, Z float64 `json:"x,y,z"`
|
||||
W, D, H float64 `json:"w,d,h"`
|
||||
ZoneType string `json:"zone_type"`
|
||||
Occupancy int `json:"occupancy"`
|
||||
People []string `json:"people"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Portal represents a doorway between zones.
|
||||
type Portal struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ZoneA string `json:"zone_a"`
|
||||
ZoneB string `json:"zone_b"`
|
||||
Points [2][2]float64 `json:"points"` // [[x1,y1], [x2,y2]]
|
||||
Crossings int `json:"crossings"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewZonesHandler creates a new zones handler.
|
||||
func NewZonesHandler(dbPath string) (*ZonesHandler, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
z := &ZonesHandler{
|
||||
db: db,
|
||||
zones: make(map[string]*Zone),
|
||||
portals: make(map[string]*Portal),
|
||||
}
|
||||
|
||||
if err := z.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := z.loadZones(); err != nil {
|
||||
log.Printf("[WARN] Failed to load zones: %v", err)
|
||||
}
|
||||
if err := z.loadPortals(); err != nil {
|
||||
log.Printf("[WARN] Failed to load portals: %v", err)
|
||||
}
|
||||
|
||||
return z, nil
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) migrate() error {
|
||||
_, err := z.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
x REAL NOT NULL DEFAULT 0,
|
||||
y REAL NOT NULL DEFAULT 0,
|
||||
z REAL NOT NULL DEFAULT 0,
|
||||
w REAL NOT NULL DEFAULT 1,
|
||||
d REAL NOT NULL DEFAULT 1,
|
||||
h REAL NOT NULL DEFAULT 1,
|
||||
zone_type TEXT NOT NULL DEFAULT 'general',
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portals (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
zone_a_id TEXT NOT NULL DEFAULT '',
|
||||
zone_b_id TEXT NOT NULL DEFAULT '',
|
||||
points_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_crossings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
portal_id TEXT NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
blob_id INTEGER,
|
||||
person TEXT DEFAULT '',
|
||||
FOREIGN KEY (portal_id) REFERENCES portals(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_crossings_portal ON portal_crossings(portal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_crossings_time ON portal_crossings(timestamp_ms);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) loadZones() error {
|
||||
rows, err := z.db.Query(`SELECT id, name, x, y, z, w, d, h, zone_type, created_at FROM zones`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var zone Zone
|
||||
var createdNS int64
|
||||
if err := rows.Scan(&zone.ID, &zone.Name, &zone.X, &zone.Y, &zone.Z,
|
||||
&zone.W, &zone.D, &zone.H, &zone.ZoneType, &createdNS); err != nil {
|
||||
continue
|
||||
}
|
||||
zone.CreatedAt = time.Unix(0, createdNS)
|
||||
zone.People = []string{}
|
||||
z.zones[zone.ID] = &zone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) loadPortals() error {
|
||||
rows, err := z.db.Query(`SELECT id, name, zone_a_id, zone_b_id, points_json, created_at FROM portals`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var portal Portal
|
||||
var pointsJSON string
|
||||
var createdNS int64
|
||||
if err := rows.Scan(&portal.ID, &portal.Name, &portal.ZoneA, &portal.ZoneB,
|
||||
&pointsJSON, &createdNS); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(pointsJSON), &portal.Points); err != nil {
|
||||
log.Printf("[WARN] Failed to parse portal points: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
portal.CreatedAt = time.Unix(0, createdNS)
|
||||
z.portals[portal.ID] = &portal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (z *ZonesHandler) Close() error {
|
||||
return z.db.Close()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers zones and portals endpoints.
|
||||
//
|
||||
// Zones:
|
||||
// GET /api/zones — list all zones
|
||||
// POST /api/zones — create zone
|
||||
// PUT /api/zones/{id} — update zone
|
||||
// DELETE /api/zones/{id} — delete zone
|
||||
// GET /api/zones/{id}/history — zone occupancy history
|
||||
//
|
||||
// Portals:
|
||||
// GET /api/portals — list all portals
|
||||
// POST /api/portals — create portal
|
||||
// PUT /api/portals/{id} — update
|
||||
// DELETE /api/portals/{id} — delete
|
||||
// GET /api/portals/{id}/crossings — portal crossing log
|
||||
func (z *ZonesHandler) RegisterRoutes(r chi.Router) {
|
||||
// Zones
|
||||
r.Get("/api/zones", z.listZones)
|
||||
r.Post("/api/zones", z.createZone)
|
||||
r.Put("/api/zones/{id}", z.updateZone)
|
||||
r.Delete("/api/zones/{id}", z.deleteZone)
|
||||
r.Get("/api/zones/{id}/history", z.getZoneHistory)
|
||||
|
||||
// Portals
|
||||
r.Get("/api/portals", z.listPortals)
|
||||
r.Post("/api/portals", z.createPortal)
|
||||
r.Put("/api/portals/{id}", z.updatePortal)
|
||||
r.Delete("/api/portals/{id}", z.deletePortal)
|
||||
r.Get("/api/portals/{id}/crossings", z.getPortalCrossings)
|
||||
}
|
||||
|
||||
// ── Zones ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (z *ZonesHandler) listZones(w http.ResponseWriter, r *http.Request) {
|
||||
z.mu.RLock()
|
||||
zones := make([]*Zone, 0, len(z.zones))
|
||||
for _, zone := range z.zones {
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
z.mu.RUnlock()
|
||||
|
||||
writeJSON(w, zones)
|
||||
}
|
||||
|
||||
type createZoneRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
W float64 `json:"w"`
|
||||
D float64 `json:"d"`
|
||||
H float64 `json:"h"`
|
||||
ZoneType string `json:"zone_type,omitempty"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) createZone(w http.ResponseWriter, r *http.Request) {
|
||||
var req createZoneRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID == "" {
|
||||
http.Error(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
http.Error(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.W <= 0 || req.D <= 0 || req.H <= 0 {
|
||||
http.Error(w, "dimensions must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
zoneType := req.ZoneType
|
||||
if zoneType == "" {
|
||||
zoneType = "general"
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
_, err := z.db.Exec(`
|
||||
INSERT INTO zones (id, name, x, y, z, w, d, h, zone_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, req.ID, req.Name, req.X, req.Y, req.Z, req.W, req.D, req.H, zoneType, now)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create zone", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
z.mu.Lock()
|
||||
z.zones[req.ID] = &Zone{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
X: req.X,
|
||||
Y: req.Y,
|
||||
Z: req.Z,
|
||||
W: req.W,
|
||||
D: req.D,
|
||||
H: req.H,
|
||||
ZoneType: zoneType,
|
||||
CreatedAt: time.Unix(0, now),
|
||||
People: []string{},
|
||||
}
|
||||
z.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, z.zones[req.ID])
|
||||
}
|
||||
|
||||
type updateZoneRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
X *float64 `json:"x,omitempty"`
|
||||
Y *float64 `json:"y,omitempty"`
|
||||
Z *float64 `json:"z,omitempty"`
|
||||
W *float64 `json:"w,omitempty"`
|
||||
D *float64 `json:"d,omitempty"`
|
||||
H *float64 `json:"h,omitempty"`
|
||||
ZoneType *string `json:"zone_type,omitempty"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) updateZone(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
zone, exists := z.zones[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "zone not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateZoneRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updates := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if req.Name != nil {
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, *req.Name)
|
||||
}
|
||||
if req.X != nil {
|
||||
updates = append(updates, "x = ?")
|
||||
args = append(args, *req.X)
|
||||
}
|
||||
if req.Y != nil {
|
||||
updates = append(updates, "y = ?")
|
||||
args = append(args, *req.Y)
|
||||
}
|
||||
if req.Z != nil {
|
||||
updates = append(updates, "z = ?")
|
||||
args = append(args, *req.Z)
|
||||
}
|
||||
if req.W != nil {
|
||||
if *req.W <= 0 {
|
||||
http.Error(w, "width must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updates = append(updates, "w = ?")
|
||||
args = append(args, *req.W)
|
||||
}
|
||||
if req.D != nil {
|
||||
if *req.D <= 0 {
|
||||
http.Error(w, "depth must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updates = append(updates, "d = ?")
|
||||
args = append(args, *req.D)
|
||||
}
|
||||
if req.H != nil {
|
||||
if *req.H <= 0 {
|
||||
http.Error(w, "height must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updates = append(updates, "h = ?")
|
||||
args = append(args, *req.H)
|
||||
}
|
||||
if req.ZoneType != nil {
|
||||
updates = append(updates, "zone_type = ?")
|
||||
args = append(args, *req.ZoneType)
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
writeJSON(w, zone)
|
||||
return
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := "UPDATE zones SET " + joinComma(updates) + " WHERE id = ?"
|
||||
|
||||
_, err := z.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to update zone", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory copy
|
||||
z.mu.Lock()
|
||||
if req.Name != nil {
|
||||
zone.Name = *req.Name
|
||||
}
|
||||
if req.X != nil {
|
||||
zone.X = *req.X
|
||||
}
|
||||
if req.Y != nil {
|
||||
zone.Y = *req.Y
|
||||
}
|
||||
if req.Z != nil {
|
||||
zone.Z = *req.Z
|
||||
}
|
||||
if req.W != nil {
|
||||
zone.W = *req.W
|
||||
}
|
||||
if req.D != nil {
|
||||
zone.D = *req.D
|
||||
}
|
||||
if req.H != nil {
|
||||
zone.H = *req.H
|
||||
}
|
||||
if req.ZoneType != nil {
|
||||
zone.ZoneType = *req.ZoneType
|
||||
}
|
||||
z.mu.Unlock()
|
||||
|
||||
writeJSON(w, zone)
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) deleteZone(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
_, exists := z.zones[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "zone not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := z.db.Exec(`DELETE FROM zones WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete zone", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
z.mu.Lock()
|
||||
delete(z.zones, id)
|
||||
z.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Count int `json:"count"`
|
||||
People []string `json:"people"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) getZoneHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
_, exists := z.zones[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "zone not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
period := r.URL.Query().Get("period")
|
||||
limit := 24
|
||||
if period == "7d" {
|
||||
limit = 24 * 7
|
||||
} else if period == "30d" {
|
||||
limit = 24 * 30
|
||||
}
|
||||
|
||||
// Generate synthetic history data (in real implementation, query from events)
|
||||
history := make([]historyEntry, limit)
|
||||
now := time.Now()
|
||||
for i := range history {
|
||||
h := historyEntry{
|
||||
Timestamp: now.Add(-time.Duration(i) * time.Hour).UnixNano() / 1e6,
|
||||
Count: 0,
|
||||
People: []string{},
|
||||
}
|
||||
history[i] = h
|
||||
}
|
||||
|
||||
writeJSON(w, history)
|
||||
}
|
||||
|
||||
// ── Portals ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (z *ZonesHandler) listPortals(w http.ResponseWriter, r *http.Request) {
|
||||
z.mu.RLock()
|
||||
portals := make([]*Portal, 0, len(z.portals))
|
||||
for _, portal := range z.portals {
|
||||
portals = append(portals, portal)
|
||||
}
|
||||
z.mu.RUnlock()
|
||||
|
||||
writeJSON(w, portals)
|
||||
}
|
||||
|
||||
type createPortalRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ZoneA string `json:"zone_a"`
|
||||
ZoneB string `json:"zone_b"`
|
||||
Points [2][2]float64 `json:"points"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) createPortal(w http.ResponseWriter, r *http.Request) {
|
||||
var req createPortalRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID == "" {
|
||||
http.Error(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.ZoneA == "" || req.ZoneB == "" {
|
||||
http.Error(w, "zone_a and zone_b are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
z.mu.RLock()
|
||||
_, zoneAExists := z.zones[req.ZoneA]
|
||||
_, zoneBExists := z.zones[req.ZoneB]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !zoneAExists || !zoneBExists {
|
||||
http.Error(w, "one or both zones not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pointsJSON, _ := json.Marshal(req.Points)
|
||||
now := time.Now().UnixNano()
|
||||
_, err := z.db.Exec(`
|
||||
INSERT INTO portals (id, name, zone_a_id, zone_b_id, points_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, req.ID, req.Name, req.ZoneA, req.ZoneB, string(pointsJSON), now)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create portal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
z.mu.Lock()
|
||||
z.portals[req.ID] = &Portal{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
ZoneA: req.ZoneA,
|
||||
ZoneB: req.ZoneB,
|
||||
Points: req.Points,
|
||||
CreatedAt: time.Unix(0, now),
|
||||
}
|
||||
z.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(w, z.portals[req.ID])
|
||||
}
|
||||
|
||||
type updatePortalRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
ZoneA *string `json:"zone_a,omitempty"`
|
||||
ZoneB *string `json:"zone_b,omitempty"`
|
||||
Points *[2][2]float64 `json:"points,omitempty"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) updatePortal(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
portal, exists := z.portals[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "portal not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req updatePortalRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
updates := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if req.Name != nil {
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, *req.Name)
|
||||
}
|
||||
if req.ZoneA != nil {
|
||||
updates = append(updates, "zone_a_id = ?")
|
||||
args = append(args, *req.ZoneA)
|
||||
}
|
||||
if req.ZoneB != nil {
|
||||
updates = append(updates, "zone_b_id = ?")
|
||||
args = append(args, *req.ZoneB)
|
||||
}
|
||||
if req.Points != nil {
|
||||
pointsJSON, _ := json.Marshal(req.Points)
|
||||
updates = append(updates, "points_json = ?")
|
||||
args = append(args, string(pointsJSON))
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
writeJSON(w, portal)
|
||||
return
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := "UPDATE portals SET " + joinComma(updates) + " WHERE id = ?"
|
||||
|
||||
_, err := z.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to update portal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory copy
|
||||
z.mu.Lock()
|
||||
if req.Name != nil {
|
||||
portal.Name = *req.Name
|
||||
}
|
||||
if req.ZoneA != nil {
|
||||
portal.ZoneA = *req.ZoneA
|
||||
}
|
||||
if req.ZoneB != nil {
|
||||
portal.ZoneB = *req.ZoneB
|
||||
}
|
||||
if req.Points != nil {
|
||||
portal.Points = *req.Points
|
||||
}
|
||||
z.mu.Unlock()
|
||||
|
||||
writeJSON(w, portal)
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) deletePortal(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
_, exists := z.portals[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "portal not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := z.db.Exec(`DELETE FROM portals WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to delete portal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
z.mu.Lock()
|
||||
delete(z.portals, id)
|
||||
z.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type crossingEntry struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
Direction string `json:"direction"`
|
||||
Person string `json:"person,omitempty"`
|
||||
}
|
||||
|
||||
func (z *ZonesHandler) getPortalCrossings(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
z.mu.RLock()
|
||||
_, exists := z.portals[id]
|
||||
z.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
http.Error(w, "portal not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 50
|
||||
if limitStr != "" {
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := z.db.Query(`
|
||||
SELECT id, timestamp_ms, direction, person
|
||||
FROM portal_crossings
|
||||
WHERE portal_id = ?
|
||||
ORDER BY timestamp_ms DESC
|
||||
LIMIT ?
|
||||
`, id, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to query crossings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var crossings []crossingEntry
|
||||
for rows.Next() {
|
||||
var c crossingEntry
|
||||
if err := rows.Scan(&c.ID, &c.Timestamp, &c.Direction, &c.Person); err != nil {
|
||||
continue
|
||||
}
|
||||
crossings = append(crossings, c)
|
||||
}
|
||||
|
||||
writeJSON(w, crossings)
|
||||
}
|
||||
|
||||
// ── Occupancy updates (called by fusion engine) ───────────────────────────────────
|
||||
|
||||
// UpdateOccupancy updates the current occupancy for all zones.
|
||||
func (z *ZonesHandler) UpdateOccupancy(occupancy map[string]int) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
|
||||
for id, zone := range z.zones {
|
||||
count := occupancy[id]
|
||||
zone.Occupancy = count
|
||||
}
|
||||
}
|
||||
|
||||
func joinComma(parts []string) string {
|
||||
result := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += p
|
||||
}
|
||||
return result
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue