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:
jedarden 2026-04-07 14:20:00 -04:00
parent f2ff68481c
commit 008d3caa60
7 changed files with 173 additions and 30 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
a4e4cff5ae8105a980e9e439ac6916dec7161cc9
04129addd309e35bd0a9f98e9d718943185552a0

View file

@ -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()

View file

@ -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()
}

View file

@ -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)

View file

@ -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.

View file

@ -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