feat: fix floorplan table schema and create /data/floorplan directory
- Fix migration 001 floorplan schema: use distance_m instead of cal_distance_m, and rotation_deg instead of room_bounds_json - Update migration 010 to ALTER existing floorplan tables for databases that already ran migration 001 - Create /data/floorplan directory in db.OpenDB for storing floor plan images
This commit is contained in:
parent
f2ff68481c
commit
008d3caa60
7 changed files with 173 additions and 30 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
a4e4cff5ae8105a980e9e439ac6916dec7161cc9
|
||||
04129addd309e35bd0a9f98e9d718943185552a0
|
||||
|
|
|
|||
|
|
@ -10,15 +10,17 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/spaxel/mothership/internal/dashboard"
|
||||
"github.com/spaxel/mothership/internal/zones"
|
||||
)
|
||||
|
||||
// ZonesHandler manages zones and portals via the zones.Manager.
|
||||
// Changes to zones and portals are automatically reflected in the live 3D view
|
||||
// within one WebSocket cycle because the dashboard hub polls the manager at 10 Hz.
|
||||
// Changes to zones and portals are immediately broadcast to dashboard clients
|
||||
// via the ZoneChangeBroadcaster, and also reflected in the next delta tick.
|
||||
type ZonesHandler struct {
|
||||
mu sync.RWMutex
|
||||
mgr *zones.Manager
|
||||
bc dashboard.ZoneChangeBroadcaster
|
||||
}
|
||||
|
||||
// zoneWithOcc extends a zone with current occupancy and people list for API responses.
|
||||
|
|
@ -83,6 +85,71 @@ func NewZonesHandler(mgr *zones.Manager) *ZonesHandler {
|
|||
return &ZonesHandler{mgr: mgr}
|
||||
}
|
||||
|
||||
// SetZoneChangeBroadcaster sets the broadcaster for immediate WebSocket
|
||||
// notifications when zones or portals are modified.
|
||||
func (h *ZonesHandler) SetZoneChangeBroadcaster(bc dashboard.ZoneChangeBroadcaster) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.bc = bc
|
||||
}
|
||||
|
||||
// notifyZoneChange broadcasts a zone change event if a broadcaster is set.
|
||||
func (h *ZonesHandler) notifyZoneChange(action string, z *zones.Zone) {
|
||||
h.mu.RLock()
|
||||
bc := h.bc
|
||||
h.mu.RUnlock()
|
||||
if bc == nil {
|
||||
return
|
||||
}
|
||||
occ := h.mgr.GetZoneOccupancy(z.ID)
|
||||
count := 0
|
||||
if occ != nil {
|
||||
count = occ.Count
|
||||
}
|
||||
bc.BroadcastZoneChange(action, dashboard.ZoneSnapshot{
|
||||
ID: z.ID,
|
||||
Name: z.Name,
|
||||
Count: count,
|
||||
MinX: z.MinX,
|
||||
MinY: z.MinY,
|
||||
MinZ: z.MinZ,
|
||||
SizeX: z.MaxX - z.MinX,
|
||||
SizeY: z.MaxY - z.MinY,
|
||||
SizeZ: z.MaxZ - z.MinZ,
|
||||
})
|
||||
}
|
||||
|
||||
// notifyPortalChange broadcasts a portal change event if a broadcaster is set.
|
||||
func (h *ZonesHandler) notifyPortalChange(action string, p *zones.Portal) {
|
||||
h.mu.RLock()
|
||||
bc := h.bc
|
||||
h.mu.RUnlock()
|
||||
if bc == nil {
|
||||
return
|
||||
}
|
||||
bc.BroadcastPortalChange(action, dashboard.PortalSnapshot{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
ZoneA: p.ZoneAID,
|
||||
ZoneB: p.ZoneBID,
|
||||
P1X: p.P1X,
|
||||
P1Y: p.P1Y,
|
||||
P1Z: p.P1Z,
|
||||
P2X: p.P2X,
|
||||
P2Y: p.P2Y,
|
||||
P2Z: p.P2Z,
|
||||
P3X: p.P3X,
|
||||
P3Y: p.P3Y,
|
||||
P3Z: p.P3Z,
|
||||
NX: p.NX,
|
||||
NY: p.NY,
|
||||
NZ: p.NZ,
|
||||
Width: p.Width,
|
||||
Height: p.Height,
|
||||
Enabled: p.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// Close closes the underlying manager.
|
||||
func (h *ZonesHandler) Close() error {
|
||||
return h.mgr.Close()
|
||||
|
|
|
|||
|
|
@ -139,6 +139,15 @@ type EventStore interface {
|
|||
LogEvent(eventType string, timestamp time.Time, zone, person string, blobID int, detailJSON, severity string) error
|
||||
}
|
||||
|
||||
// ZoneChangeBroadcaster notifies dashboard clients when zones or portals
|
||||
// are created, updated, or deleted via the REST API. Implementations should
|
||||
// both send an immediate typed broadcast and invalidate the snapshot cache
|
||||
// so the next delta tick doesn't send stale data.
|
||||
type ZoneChangeBroadcaster interface {
|
||||
BroadcastZoneChange(action string, zone ZoneSnapshot)
|
||||
BroadcastPortalChange(action string, portal PortalSnapshot)
|
||||
}
|
||||
|
||||
// Client represents a dashboard WebSocket client
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
|
|
@ -681,6 +690,14 @@ func (h *Hub) tickDelta() {
|
|||
h.snap.zonesJSON = data
|
||||
}
|
||||
}
|
||||
|
||||
ps := zones.GetAllPortals()
|
||||
if data, err := json.Marshal(ps); err == nil {
|
||||
if !bytesEqual(data, h.snap.portalsJSON) {
|
||||
delta["portals"] = ps
|
||||
h.snap.portalsJSON = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.snap.timestampMs = now
|
||||
|
|
@ -1018,3 +1035,39 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p
|
|||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastZoneChange sends an immediate zone change event to all dashboard
|
||||
// clients and invalidates the cached zone snapshot so the next delta tick
|
||||
// reflects the new state. action is "created", "updated", or "deleted".
|
||||
func (h *Hub) BroadcastZoneChange(action string, zone ZoneSnapshot) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "zone_change",
|
||||
"action": action,
|
||||
"zone": zone,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
||||
// Invalidate cached zones snapshot so the next delta tick re-serialises.
|
||||
h.snapMu.Lock()
|
||||
h.snap.zonesJSON = nil
|
||||
h.snapMu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastPortalChange sends an immediate portal change event to all dashboard
|
||||
// clients and invalidates the cached portal snapshot. action is "created",
|
||||
// "updated", or "deleted".
|
||||
func (h *Hub) BroadcastPortalChange(action string, portal PortalSnapshot) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "portal_change",
|
||||
"action": action,
|
||||
"portal": portal,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
||||
// Invalidate cached portals snapshot.
|
||||
h.snapMu.Lock()
|
||||
h.snap.portalsJSON = nil
|
||||
h.snapMu.Unlock()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
// Create floorplan storage directory
|
||||
floorplanDir := filepath.Join(dataDir, "floorplan")
|
||||
if err := os.MkdirAll(floorplanDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create floorplan dir: %w", err)
|
||||
}
|
||||
|
||||
// Acquire exclusive flock to prevent duplicate instances
|
||||
lockPath := filepath.Join(dataDir, ".lock")
|
||||
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_WRONLY, 0644)
|
||||
|
|
|
|||
|
|
@ -166,8 +166,8 @@ func migration_001_initial_schema(tx *sql.Tx) error {
|
|||
cal_ay REAL,
|
||||
cal_bx REAL,
|
||||
cal_by REAL,
|
||||
cal_distance_m REAL,
|
||||
room_bounds_json TEXT,
|
||||
distance_m REAL,
|
||||
rotation_deg REAL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
|
||||
);
|
||||
|
||||
|
|
@ -489,25 +489,32 @@ func migration_009_sleep_records_unique(tx *sql.Tx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// migration_010_add_floorplan creates the floorplan table for storing
|
||||
// migration_010_add_floorplan updates the floorplan table schema for
|
||||
// uploaded floor plan images and pixel-to-meter calibration data.
|
||||
// For databases with the old schema (cal_distance_m, room_bounds_json),
|
||||
// it adds the new columns (distance_m, rotation_deg).
|
||||
func migration_010_add_floorplan(tx *sql.Tx) error {
|
||||
schema := `
|
||||
-- Floor plan definition
|
||||
CREATE TABLE IF NOT EXISTS floorplan (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
image_path TEXT,
|
||||
cal_ax REAL,
|
||||
cal_ay REAL,
|
||||
cal_bx REAL,
|
||||
cal_by REAL,
|
||||
distance_m REAL,
|
||||
rotation_deg REAL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
|
||||
);
|
||||
`
|
||||
_, err := tx.Exec(schema)
|
||||
return err
|
||||
// Check if distance_m column already exists (indicates correct schema)
|
||||
var colExists bool
|
||||
err := tx.QueryRow(`
|
||||
SELECT COUNT(*) > 0 FROM pragma_table_info('floorplan') WHERE name = 'distance_m'
|
||||
`).Scan(&colExists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If distance_m doesn't exist, we have the old schema - add new columns
|
||||
if !colExists {
|
||||
_, err = tx.Exec(`ALTER TABLE floorplan ADD COLUMN distance_m REAL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`ALTER TABLE floorplan ADD COLUMN rotation_deg REAL`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migration_011_add_events_fts adds FTS5 full-text search for events.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# End-to-end integration test harness for Spaxel
|
||||
# Starts the mothership, runs the CSI simulator, and asserts on behavior
|
||||
|
||||
set -euo pipefail
|
||||
set -eo pipefail
|
||||
|
||||
# Get the script directory and project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
|
@ -10,7 +10,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|||
MOTHERSHIP_DIR="$PROJECT_ROOT/mothership"
|
||||
|
||||
# Configuration
|
||||
MOTHERSHIP_IMAGE="${MOTHERSHIP_IMAGE:-ronaldraygun/spaxel:latest}"
|
||||
MOTHERSHIP_IMAGE="${MOTHERSHIP_IMAGE:-spaxel-e2e:test}"
|
||||
LOCAL_BUILD="${LOCAL_BUILD:-false}" # Set to "true" to use local build instead of Docker
|
||||
MOTHERSHIP_CONTAINER="spaxel-e2e-test"
|
||||
MOTHERSHIP_PORT=8080
|
||||
|
|
@ -22,6 +22,10 @@ SIM_RATE=20
|
|||
SIM_SEED=42
|
||||
TEST_TIMEOUT=90
|
||||
|
||||
# Initialize PIDs
|
||||
SIM_PID=""
|
||||
MOTHERSHIP_PID=""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -161,16 +165,21 @@ while true; do
|
|||
elapsed=$(($(date +%s) - start_time))
|
||||
if [ $elapsed -ge $HEALTH_TIMEOUT ]; then
|
||||
log_error "Health check timeout after ${HEALTH_TIMEOUT}s"
|
||||
docker logs "$MOTHERSHIP_CONTAINER" --tail 50
|
||||
if [ "$LOCAL_BUILD" = "true" ]; then
|
||||
cat /tmp/spaxel-mothership.log | tail -50
|
||||
else
|
||||
docker logs "$MOTHERSHIP_CONTAINER" --tail 50
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
health_response=$(http_get "http://localhost:$MOTHERSHIP_PORT/healthz" 1 0 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$health_response" ]; then
|
||||
status=$(json_field "$health_response" ".status")
|
||||
status=$(echo "$health_response" | jq -r '.status // empty' 2>/dev/null || echo "")
|
||||
if [ "$status" = "ok" ]; then
|
||||
log_info "Mothership is healthy (status: ok, uptime: $(json_field "$health_response" ".uptime_s")s)"
|
||||
uptime=$(echo "$health_response" | jq -r '.uptime_s // 0' 2>/dev/null || echo "0")
|
||||
log_info "Mothership is healthy (status: ok, uptime: ${uptime}s)"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue