spaxel/mothership/internal/dashboard/server.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

335 lines
8.4 KiB
Go

package dashboard
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/spaxel/mothership/internal/replay"
)
// parseISO8601 parses an ISO8601 timestamp string and returns Unix milliseconds
func parseISO8601(s string) (int64, error) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
// Try alternative formats
t, err = time.Parse("2006-01-02T15:04:05", s)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z", s)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05.999Z", s)
if err != nil {
return 0, err
}
}
}
}
return t.UnixMilli(), nil
}
const (
// Dashboard ping/pong timing
dashboardPingInterval = 30 * time.Second
dashboardReadDeadline = 60 * time.Second
// Send buffer size per client
sendBufferSize = 1024
)
// Server handles WebSocket connections from dashboard clients
type Server struct {
hub *Hub
upgrader websocket.Upgrader
}
// NewServer creates a new dashboard server
func NewServer(hub *Hub) *Server {
return &Server{
hub: hub,
upgrader: websocket.Upgrader{
// Allow all origins for development
CheckOrigin: func(r *http.Request) bool {
return true
},
ReadBufferSize: 256,
WriteBufferSize: 4096,
},
}
}
// HandleDashboardWS handles WebSocket connections at /ws/dashboard
func (s *Server) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Upgrade HTTP connection to WebSocket
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[WARN] Dashboard WebSocket upgrade failed: %v", err)
return
}
// Create client
client := &Client{
hub: s.hub,
send: make(chan []byte, sendBufferSize),
}
// Register with hub
s.hub.Register(client)
// Start write goroutine
go s.writePump(conn, client)
// Run read pump in this goroutine
s.readPump(conn, client)
}
// readPump handles incoming messages from the dashboard client
func (s *Server) readPump(conn *websocket.Conn, client *Client) {
defer func() {
conn.Close() //nolint:errcheck
s.hub.Unregister(client)
}()
// Set read deadline
_ = conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline)) //nolint:errcheck
// Set pong handler to reset deadline
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline)) //nolint:errcheck
return nil
})
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("[WARN] Dashboard read error: %v", err)
}
break
}
// Handle WebSocket commands from dashboard
s.handleCommand(message, client)
}
}
// handleCommand processes WebSocket commands from the dashboard client
func (s *Server) handleCommand(data []byte, client *Client) {
var cmd map[string]interface{}
if err := json.Unmarshal(data, &cmd); err != nil {
log.Printf("[DEBUG] Failed to parse WebSocket command: %v", err)
return
}
cmdType, ok := cmd["type"].(string)
if !ok {
return
}
switch cmdType {
case "replay_seek":
s.handleReplaySeek(cmd)
case "replay_play":
s.handleReplayPlay(cmd)
case "replay_pause":
s.handleReplayPause(cmd)
case "replay_set_params":
s.handleReplaySetParams(cmd)
case "replay_apply_to_live":
s.handleReplayApplyToLive(cmd)
case "replay_set_speed":
s.handleReplaySetSpeed(cmd)
case "request_explain":
s.handleRequestExplain(cmd)
default:
// Unknown command type - ignore
log.Printf("[DEBUG] Unknown WebSocket command type: %s", cmdType)
}
}
// handleReplaySeek handles replay_seek commands
func (s *Server) handleReplaySeek(cmd map[string]interface{}) {
targetISO, ok := cmd["timestamp_iso8601"].(string)
if !ok {
log.Printf("[WARN] replay_seek missing timestamp_iso8601")
return
}
targetMS, err := parseISO8601(targetISO)
if err != nil {
log.Printf("[WARN] replay_seek invalid timestamp: %v", err)
return
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.SeekTo(targetMS) //nolint:errcheck
}
}
// handleReplayPlay handles replay_play commands
func (s *Server) handleReplayPlay(cmd map[string]interface{}) {
speedVal, ok := cmd["speed"]
var speed float64 = 1.0
if ok {
switch v := speedVal.(type) {
case float64:
speed = v
case int:
speed = float64(v)
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.Play(speed) //nolint:errcheck
}
}
// handleReplayPause handles replay_pause commands
func (s *Server) handleReplayPause(cmd map[string]interface{}) {
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.Pause() //nolint:errcheck
}
}
// handleReplaySetParams handles replay_set_params commands
func (s *Server) handleReplaySetParams(cmd map[string]interface{}) {
params := &replay.TunableParams{}
if val, ok := cmd["delta_rms_threshold"]; ok {
if f, ok := val.(float64); ok {
params.DeltaRMSThreshold = &f
}
}
if val, ok := cmd["tau_s"]; ok {
if f, ok := val.(float64); ok {
params.TauS = &f
}
}
if val, ok := cmd["fresnel_decay"]; ok {
if f, ok := val.(float64); ok {
params.FresnelDecay = &f
}
}
if val, ok := cmd["n_subcarriers"]; ok {
if i, ok := val.(float64); ok {
ival := int(i)
params.NSubcarriers = &ival
}
}
if val, ok := cmd["breathing_sensitivity"]; ok {
if f, ok := val.(float64); ok {
params.BreathingSensitivity = &f
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.SetParams(params) //nolint:errcheck
}
}
// handleReplayApplyToLive handles replay_apply_to_live commands
func (s *Server) handleReplayApplyToLive(cmd map[string]interface{}) {
// This would copy replay parameters to live configuration
// Requires confirmation from user (handled on frontend)
log.Printf("[INFO] Apply replay parameters to live requested")
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.ApplyToLive() //nolint:errcheck
}
}
// handleReplaySetSpeed handles replay_set_speed commands
func (s *Server) handleReplaySetSpeed(cmd map[string]interface{}) {
speedVal, ok := cmd["speed"]
var speed float64 = 1.0
if ok {
switch v := speedVal.(type) {
case float64:
speed = v
case int:
speed = float64(v)
}
}
// Forward to replay handler if available
if s.hub.replayHandler != nil {
_ = s.hub.replayHandler.SetSpeed(speed) //nolint:errcheck
}
}
// writePump handles outgoing messages to the dashboard client
func (s *Server) writePump(conn *websocket.Conn, client *Client) {
ticker := time.NewTicker(dashboardPingInterval)
defer func() {
ticker.Stop()
conn.Close() //nolint:errcheck
}()
for {
select {
case message, ok := <-client.send:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
if !ok {
// Hub closed the channel
_ = conn.WriteMessage(websocket.CloseMessage, []byte{}) //nolint:errcheck
return
}
// Determine message type (binary vs text)
if len(message) > 0 && message[0] == '{' {
// Looks like JSON, send as text
err := conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("[WARN] Dashboard write error: %v", err)
return
}
} else {
// Binary CSI frame
err := conn.WriteMessage(websocket.BinaryMessage, message)
if err != nil {
log.Printf("[WARN] Dashboard write error: %v", err)
return
}
}
case <-ticker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// handleRequestExplain handles "request_explain" commands from the dashboard.
// The client sends {"type":"request_explain","blob_id":N} to request that the
// server emit a "blob_explain" message on the next fusion tick.
func (s *Server) handleRequestExplain(cmd map[string]interface{}) {
var blobID int
switch v := cmd["blob_id"].(type) {
case float64:
blobID = int(v)
case int:
blobID = v
default:
log.Printf("[WARN] request_explain: missing or invalid blob_id field")
return
}
s.hub.RequestExplain(blobID)
log.Printf("[DEBUG] request_explain queued for blob %d", blobID)
}
// Hub returns the server's hub for external use
func (s *Server) Hub() *Hub {
return s.hub
}
// Client represents a dashboard WebSocket client
// (redeclared here for documentation; defined in hub.go)
type dashboardClient = Client