From c0ae81a6b0ae813077d85654e29397ffce86aecd Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 24 May 2026 15:30:02 -0400 Subject: [PATCH] style(mothership): run go fmt to format all Go code Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting. --- mothership/cmd/mothership/migrate.go | 10 +- mothership/cmd/sim/generator.go | 18 +- mothership/cmd/sim/main.go | 92 +++---- mothership/cmd/sim/main_test.go | 42 ++-- mothership/cmd/sim/scenario.go | 38 +-- mothership/cmd/sim/verify.go | 4 +- .../internal/analytics/alert_handler.go | 80 +++--- mothership/internal/analytics/anomaly.go | 180 ++++++------- mothership/internal/analytics/handler.go | 7 +- mothership/internal/analytics/patterns.go | 4 +- .../internal/analytics/patterns_test.go | 16 +- mothership/internal/apdetector/detector.go | 24 +- mothership/internal/api/alerts.go | 27 +- mothership/internal/api/analytics.go | 4 +- mothership/internal/api/baseline.go | 4 +- mothership/internal/api/baseline_test.go | 2 +- mothership/internal/api/ble_test.go | 24 +- mothership/internal/api/briefing.go | 18 +- mothership/internal/api/diurnal.go | 1 - mothership/internal/api/events.go | 27 +- mothership/internal/api/feedback.go | 22 +- mothership/internal/api/guided.go | 50 ++-- mothership/internal/api/integrations.go | 20 +- mothership/internal/api/localization.go | 30 +-- mothership/internal/api/localization_test.go | 8 +- .../internal/api/notification_settings.go | 21 +- .../api/notification_settings_test.go | 8 +- mothership/internal/api/notifications.go | 18 +- mothership/internal/api/prediction.go | 20 +- mothership/internal/api/replay.go | 36 +-- mothership/internal/api/replay_test.go | 24 +- mothership/internal/api/security.go | 58 +++-- mothership/internal/api/security_test.go | 34 +-- mothership/internal/api/settings.go | 50 ++-- mothership/internal/api/settings_test.go | 38 +-- mothership/internal/api/status.go | 10 +- mothership/internal/api/triggers.go | 26 +- mothership/internal/api/triggers_test.go | 40 +-- mothership/internal/api/volume_triggers.go | 12 +- .../internal/api/volume_triggers_test.go | 48 ++-- mothership/internal/api/zones.go | 8 +- mothership/internal/api/zones_test.go | 28 +-- mothership/internal/auth/handler.go | 4 +- mothership/internal/auth/handler_test.go | 8 +- mothership/internal/automation/engine_test.go | 86 +++---- mothership/internal/autoupdate/adapters.go | 8 +- mothership/internal/ble/handler.go | 8 +- mothership/internal/ble/identity.go | 46 ++-- mothership/internal/ble/identity_test.go | 30 +-- mothership/internal/ble/registry.go | 74 +++--- mothership/internal/ble/rotation.go | 44 ++-- mothership/internal/ble/rotation_test.go | 6 +- mothership/internal/briefing/briefing.go | 2 +- mothership/internal/dashboard/hub.go | 204 +++++++-------- mothership/internal/dashboard/hub_test.go | 140 +++++------ mothership/internal/db/db.go | 18 +- mothership/internal/db/migrate_test.go | 8 +- .../internal/diagnostics/linkweather.go | 236 +++++++++--------- .../internal/diagnostics/linkweather_test.go | 12 +- mothership/internal/diagnostics/reposition.go | 6 +- mothership/internal/diskspace/monitor.go | 22 +- mothership/internal/diskspace/monitor_test.go | 16 +- mothership/internal/doctor/doctor.go | 2 +- mothership/internal/doctor/doctor_test.go | 22 +- mothership/internal/eventbus/eventbus.go | 26 +- mothership/internal/eventbus/eventbus_test.go | 6 +- mothership/internal/events/bus.go | 52 ++-- mothership/internal/events/bus_test.go | 4 +- mothership/internal/events/events.go | 30 +-- mothership/internal/events/events_test.go | 11 +- mothership/internal/events/storage.go | 30 +-- mothership/internal/events/storage_test.go | 110 ++++---- mothership/internal/events/types.go | 86 +++---- mothership/internal/explainability/handler.go | 182 +++++++------- mothership/internal/falldetect/detector.go | 118 ++++----- mothership/internal/fleet/collision.go | 18 +- mothership/internal/fleet/fleet_test.go | 4 +- mothership/internal/fleet/fleethandler.go | 56 ++--- mothership/internal/fleet/handler.go | 52 ++-- mothership/internal/fleet/healer.go | 26 +- mothership/internal/fleet/healer_test.go | 18 +- mothership/internal/fleet/manager.go | 16 +- mothership/internal/fleet/optimiser.go | 32 +-- mothership/internal/fleet/registry.go | 2 +- mothership/internal/fleet/selfheal.go | 52 ++-- mothership/internal/fleet/weather.go | 16 +- mothership/internal/floorplan/floorplan.go | 20 +- mothership/internal/fusion/explain.go | 10 +- mothership/internal/fusion/fusion.go | 58 ++--- mothership/internal/fusion/grid3d.go | 4 +- .../internal/guidedtroubleshoot/discovery.go | 2 +- .../internal/guidedtroubleshoot/quality.go | 70 +++--- .../guidedtroubleshoot/quality_test.go | 10 +- mothership/internal/health/health.go | 30 +-- mothership/internal/health/health_test.go | 42 ++-- mothership/internal/help/monitor.go | 20 +- mothership/internal/help/notifier.go | 60 ++--- mothership/internal/help/notifier_test.go | 4 +- mothership/internal/ingestion/frame.go | 19 +- .../internal/ingestion/json_fuzz_test.go | 60 ++--- mothership/internal/ingestion/message.go | 46 ++-- .../internal/ingestion/ratecontrol_test.go | 4 +- mothership/internal/ingestion/server.go | 26 +- mothership/internal/ingestion/server_test.go | 30 +-- mothership/internal/learning/accuracy.go | 10 +- .../internal/learning/feedback_store.go | 56 ++--- mothership/internal/learning/feedback_test.go | 4 +- mothership/internal/learning/handler.go | 26 +- mothership/internal/loadshed/loadshed.go | 9 +- mothership/internal/loadshed/loadshed_test.go | 14 +- mothership/internal/localization/fusion.go | 8 +- mothership/internal/localization/grid.go | 4 +- .../internal/localization/groundtruth.go | 24 +- .../localization/groundtruth_store.go | 22 +- .../internal/localization/self_improving.go | 72 +++--- .../internal/localization/spatial_weights.go | 40 +-- .../localization/spatial_weights_test.go | 34 +-- .../internal/localization/weightlearner.go | 16 +- .../localizer/fusion/timing_budget_test.go | 69 ++--- mothership/internal/mqtt/client.go | 142 +++++------ mothership/internal/mqtt/client_test.go | 52 ++-- mothership/internal/mqtt/publisher.go | 24 +- mothership/internal/notifications/manager.go | 70 +++--- .../internal/notifications/manager_test.go | 56 ++--- mothership/internal/notifications/ntfy.go | 8 +- .../internal/notifications/ntfy_test.go | 2 +- mothership/internal/notifications/pushover.go | 30 +-- .../internal/notifications/pushover_test.go | 20 +- mothership/internal/notifications/webhook.go | 36 +-- mothership/internal/notify/service.go | 47 ++-- .../internal/notify/service_delivery_test.go | 18 +- .../internal/notify/service_enhanced.go | 42 ++-- .../internal/notify/service_enhanced_test.go | 41 +-- mothership/internal/ota/autoapi.go | 28 +-- mothership/internal/ota/autoupdate.go | 50 ++-- mothership/internal/ota/autoupdate_test.go | 24 +- mothership/internal/ota/manager.go | 24 +- mothership/internal/ota/server.go | 8 +- mothership/internal/oui/gen.go | 1 + mothership/internal/oui/gen_data.go | 4 +- mothership/internal/oui/oui_test.go | 8 +- .../internal/prediction/accuracy_test.go | 24 +- mothership/internal/prediction/adapter.go | 4 +- mothership/internal/prediction/history.go | 2 +- mothership/internal/prediction/horizon.go | 36 +-- mothership/internal/provisioning/server.go | 24 +- mothership/internal/render/floorplan.go | 78 +++--- mothership/internal/render/floorplan_test.go | 135 +++++----- mothership/internal/replay/engine.go | 14 +- .../internal/replay/integration_test.go | 16 +- mothership/internal/replay/pipeline.go | 16 +- mothership/internal/replay/pipeline_test.go | 5 +- mothership/internal/replay/store.go | 10 +- mothership/internal/replay/types.go | 40 +-- mothership/internal/replay/worker.go | 1 - mothership/internal/shutdown/shutdown.go | 2 +- mothership/internal/signal/ambient.go | 71 +++--- mothership/internal/signal/ambient_test.go | 8 +- mothership/internal/signal/baseline.go | 26 +- mothership/internal/signal/breathing.go | 68 ++--- .../internal/signal/breathing_noise_test.go | 22 +- mothership/internal/signal/breathing_test.go | 1 - mothership/internal/signal/diurnal.go | 2 +- mothership/internal/signal/diurnal_test.go | 2 +- mothership/internal/signal/features.go | 28 +-- mothership/internal/signal/persist.go | 4 +- mothership/internal/signal/phase.go | 6 +- .../internal/signal/phase_property_test.go | 38 +-- mothership/internal/signal/processor.go | 52 ++-- mothership/internal/simulator/accuracy.go | 96 +++---- mothership/internal/simulator/engine.go | 44 ++-- mothership/internal/simulator/gdop.go | 38 +-- mothership/internal/simulator/handler.go | 8 +- mothership/internal/simulator/node.go | 26 +- mothership/internal/simulator/physics.go | 10 +- mothership/internal/simulator/propagation.go | 2 +- .../internal/simulator/registry_bridge.go | 26 +- mothership/internal/simulator/session.go | 34 +-- .../internal/simulator/simulator_test.go | 6 +- mothership/internal/simulator/space.go | 38 +-- mothership/internal/simulator/space_test.go | 4 +- .../internal/simulator/virtual_state.go | 34 +-- .../internal/simulator/virtual_state_test.go | 16 +- mothership/internal/simulator/walker.go | 30 +-- mothership/internal/simulator/walker_test.go | 4 +- mothership/internal/sleep/analyzer.go | 134 +++++----- .../sleep/breathing_acceptance_test.go | 12 +- .../internal/sleep/breathing_anomaly.go | 6 +- .../internal/sleep/breathing_anomaly_test.go | 16 +- .../internal/sleep/breathing_estimator.go | 16 +- .../sleep/breathing_estimator_test.go | 18 +- mothership/internal/sleep/handler.go | 14 +- mothership/internal/sleep/integration.go | 78 +++--- mothership/internal/sleep/integration_test.go | 8 +- mothership/internal/sleep/monitor_test.go | 2 +- mothership/internal/sleep/records.go | 28 +-- mothership/internal/sleep/report.go | 44 ++-- mothership/internal/sleep/storage.go | 50 ++-- mothership/internal/timeline/timeline.go | 14 +- mothership/internal/tracker/ble_provider.go | 16 +- mothership/internal/tracker/identity.go | 20 +- mothership/internal/tracker/tracker.go | 34 +-- mothership/internal/tracker/tracker_test.go | 2 +- mothership/internal/tracking/tracker.go | 30 +-- mothership/internal/tracking/ukf.go | 8 +- mothership/internal/volume/shape.go | 16 +- mothership/internal/volume/shape_test.go | 62 ++--- mothership/internal/webhook/publisher.go | 18 +- mothership/internal/zones/manager.go | 89 ++++--- mothership/internal/zones/manager_test.go | 166 ++++++------ .../acceptance/as1_first_time_setup_test.go | 14 +- .../acceptance/as2_walking_detection_test.go | 2 +- .../acceptance/as3_fall_detection_test.go | 26 +- .../test/acceptance/as4_ble_identity_test.go | 55 ++-- mothership/test/acceptance/as5_ota_test.go | 38 +-- mothership/test/acceptance/as6_replay_test.go | 16 +- 216 files changed, 3527 insertions(+), 3514 deletions(-) diff --git a/mothership/cmd/mothership/migrate.go b/mothership/cmd/mothership/migrate.go index 8a577b6..cfb257f 100644 --- a/mothership/cmd/mothership/migrate.go +++ b/mothership/cmd/mothership/migrate.go @@ -4,12 +4,14 @@ // This is a standalone binary for running migrations without starting the full application. // // Build: -// go build -tags ignore_migrate -o migrate ./cmd/mothership +// +// go build -tags ignore_migrate -o migrate ./cmd/mothership // // Usage: -// ./migrate [options] -// ./migrate --version -// ./migrate --prune +// +// ./migrate [options] +// ./migrate --version +// ./migrate --prune package main import ( diff --git a/mothership/cmd/sim/generator.go b/mothership/cmd/sim/generator.go index e338dd4..edb2822 100644 --- a/mothership/cmd/sim/generator.go +++ b/mothership/cmd/sim/generator.go @@ -8,7 +8,7 @@ import ( const ( // WiFi physical constants - wavelength = 0.123 // meters (2.4 GHz) + wavelength = 0.123 // meters (2.4 GHz) halfWavelength = wavelength / 2.0 subcarrierSpacing = 312.5e3 // Hz c = 3e8 // speed of light m/s @@ -18,9 +18,9 @@ const ( // Path loss model constants (log-distance model) // PL(d) = PL_0 + 10*n*log10(d/d_0) - pl0 = 40.0 // dBm reference power at d0 - d0 = 1.0 // meters reference distance - n = 2.0 // path loss exponent (free space) + pl0 = 40.0 // dBm reference power at d0 + d0 = 1.0 // meters reference distance + n = 2.0 // path loss exponent (free space) // Reflection coefficient (power, dimensionless) reflectionCoeff = 0.3 @@ -50,14 +50,14 @@ func generateCSIFrame(tx, rx *VirtualNode, walkers []*Walker, walls []Wall, fram frame := make([]byte, headerSize+nSub*2) // Write header (matches ingestion/frame.go ParseFrame layout) - copy(frame[0:6], tx.MAC[:]) // node_mac - copy(frame[6:12], rx.MAC[:]) // peer_mac + copy(frame[0:6], tx.MAC[:]) // node_mac + copy(frame[6:12], rx.MAC[:]) // peer_mac binary.LittleEndian.PutUint64(frame[12:20], uint64(frameNum*50000)) // timestamp_us - frame[20] = byte(rssi) // rssi + frame[20] = byte(rssi) // rssi var noiseFloor int8 = -95 - frame[21] = byte(noiseFloor) // noise_floor: -95 dBm + frame[21] = byte(noiseFloor) // noise_floor: -95 dBm frame[22] = byte(*flagChannel) // channel (from --channel flag) - frame[23] = nSub // n_sub + frame[23] = nSub // n_sub // Generate I/Q pairs for each subcarrier for k := 0; k < nSub; k++ { diff --git a/mothership/cmd/sim/main.go b/mothership/cmd/sim/main.go index dfeaeff..d57303d 100644 --- a/mothership/cmd/sim/main.go +++ b/mothership/cmd/sim/main.go @@ -35,36 +35,36 @@ const ( defaultMothership = "ws://localhost:8080/ws/node" defaultNodes = 2 defaultWalkers = 1 - defaultRate = 20 // Hz - defaultDuration = 60 // seconds - defaultChannel = 6 // 2.4 GHz channel 6 - defaultSeed = 0 // random seed (0 = use current time) + defaultRate = 20 // Hz + defaultDuration = 60 // seconds + defaultChannel = 6 // 2.4 GHz channel 6 + defaultSeed = 0 // random seed (0 = use current time) defaultSpace = "5x5x2.5" // room dimensions defaultNoiseSigma = 0.005 ) var ( // CLI flags - flagMothership = flag.String("mothership", defaultMothership, "URL of the mothership WebSocket endpoint") - flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)") - flagNodes = flag.Int("nodes", defaultNodes, "Number of virtual nodes") - flagWalkers = flag.Int("walkers", defaultWalkers, "Number of synthetic walkers") - flagRate = flag.Int("rate", defaultRate, "CSI transmission rate in Hz per node pair") - flagDuration = flag.Int("duration", defaultDuration, "Total run time in seconds (0 = run until Ctrl+C)") - flagSeed = flag.Int64("seed", defaultSeed, "Random seed for reproducible walker paths") - flagSpace = flag.String("space", defaultSpace, "Room dimensions in WxDxH format (meters)") - flagBLE = flag.Bool("ble", false, "Include synthetic BLE advertisements") - flagVerify = flag.Bool("verify", false, "Verify blob detection after duration") - flagNoiseSigma = flag.Float64("noise-sigma", defaultNoiseSigma, "Gaussian noise standard deviation for I/Q") - flagOutputCSV = flag.String("output-csv", "", "Write ground truth to CSV file") - flagChannel = flag.Int("channel", defaultChannel, "WiFi channel (1-14 for 2.4 GHz)") - flagWalkerType = flag.String("walker-type", "random", "Walker type: random, path, node-to-node") - flagPathFile = flag.String("path-file", "", "JSON file containing walker paths") + flagMothership = flag.String("mothership", defaultMothership, "URL of the mothership WebSocket endpoint") + flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)") + flagNodes = flag.Int("nodes", defaultNodes, "Number of virtual nodes") + flagWalkers = flag.Int("walkers", defaultWalkers, "Number of synthetic walkers") + flagRate = flag.Int("rate", defaultRate, "CSI transmission rate in Hz per node pair") + flagDuration = flag.Int("duration", defaultDuration, "Total run time in seconds (0 = run until Ctrl+C)") + flagSeed = flag.Int64("seed", defaultSeed, "Random seed for reproducible walker paths") + flagSpace = flag.String("space", defaultSpace, "Room dimensions in WxDxH format (meters)") + flagBLE = flag.Bool("ble", false, "Include synthetic BLE advertisements") + flagVerify = flag.Bool("verify", false, "Verify blob detection after duration") + flagNoiseSigma = flag.Float64("noise-sigma", defaultNoiseSigma, "Gaussian noise standard deviation for I/Q") + flagOutputCSV = flag.String("output-csv", "", "Write ground truth to CSV file") + flagChannel = flag.Int("channel", defaultChannel, "WiFi channel (1-14 for 2.4 GHz)") + flagWalkerType = flag.String("walker-type", "random", "Walker type: random, path, node-to-node") + flagPathFile = flag.String("path-file", "", "JSON file containing walker paths") // GDOP and shopping list flags - flagGDOPOverlay = flag.Bool("gdop-overlay", false, "Output GDOP overlay data as JSON to stdout") + flagGDOPOverlay = flag.Bool("gdop-overlay", false, "Output GDOP overlay data as JSON to stdout") flagShoppingList = flag.Bool("shopping-list", false, "Output shopping list as JSON to stdout") - flagCellSize = flag.Float64("cell-size", 0.2, "GDOP grid cell size in meters") + flagCellSize = flag.Float64("cell-size", 0.2, "GDOP grid cell size in meters") // Scenario flags flagScenario = flag.String("scenario", "normal", "Scenario type: normal, fall, ota, bag-on-couch") @@ -143,9 +143,9 @@ type VirtualNode struct { type WalkerType string const ( - WalkerTypeRandomWalk WalkerType = "random" - WalkerTypePathFollow WalkerType = "path" - WalkerTypeNodeToNode WalkerType = "node-to-node" + WalkerTypeRandomWalk WalkerType = "random" + WalkerTypePathFollow WalkerType = "path" + WalkerTypeNodeToNode WalkerType = "node-to-node" ) // Walker represents a simulated person @@ -156,10 +156,10 @@ type Walker struct { Speed float64 Height float64 Type WalkerType - Path []Point // For path-following mode - PathIdx int // Current position along path + Path []Point // For path-following mode + PathIdx int // Current position along path Nodes []*VirtualNode // For node-to-node mode - NodeIdx int // Current target node index + NodeIdx int // Current target node index } // Point represents a 3D position @@ -191,8 +191,8 @@ const ( // Wall represents a wall segment with material properties type Wall struct { X1, Y1, X2, Y2 float64 - Material WallMaterial - Attenuation float64 // dB loss + Material WallMaterial + Attenuation float64 // dB loss } // Stats tracks simulation statistics @@ -395,7 +395,7 @@ func createVirtualNodes(count int, space *Space, rng *rand.Rand) []*VirtualNode } // Distribute nodes around perimeter - perimeter := 2*(space.Width+space.Depth) + perimeter := 2 * (space.Width + space.Depth) pos := float64(i) / float64(count) * perimeter if pos < space.Width { @@ -1334,25 +1334,25 @@ func outputGDOPOverlay(space *Space, nodes []*VirtualNode, cellSize float64) err // Output JSON output := map[string]interface{}{ - "type": "gdop_overlay", + "type": "gdop_overlay", "space_dimensions": map[string]float64{ "width_m": space.Width, "depth_m": space.Depth, "height_m": space.Height, }, "grid_dimensions": []int{heatmapData.Width, heatmapData.Depth, 1}, - "cell_size_m": cellSize, - "origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY}, - "gdop_values": heatmapData.GDOPValues, - "qualities": heatmapData.Qualities, - "colors": heatmapData.Colors, - "accuracy_map": heatmapData.AccuracyMap, - "coverage_score": gdopComp.CoverageScore(results), - "average_gdop": avgGDOPOutput, - "quality_counts": gdopComp.QualityCounts(results), - "dead_zones": gdopComp.FindDeadZones(results), - "links": links, - "timestamp": time.Now().Format(time.RFC3339), + "cell_size_m": cellSize, + "origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY}, + "gdop_values": heatmapData.GDOPValues, + "qualities": heatmapData.Qualities, + "colors": heatmapData.Colors, + "accuracy_map": heatmapData.AccuracyMap, + "coverage_score": gdopComp.CoverageScore(results), + "average_gdop": avgGDOPOutput, + "quality_counts": gdopComp.QualityCounts(results), + "dead_zones": gdopComp.FindDeadZones(results), + "links": links, + "timestamp": time.Now().Format(time.RFC3339), } enc := json.NewEncoder(os.Stdout) @@ -1399,10 +1399,10 @@ func outputShoppingList(space *Space, nodes []*VirtualNode) error { // Output JSON output := map[string]interface{}{ - "type": "shopping_list", - "list": shoppingList, + "type": "shopping_list", + "list": shoppingList, "timestamp": time.Now().Format(time.RFC3339), - "note": "This is a pre-deployment estimate. Actual accuracy may vary based on environment.", + "note": "This is a pre-deployment estimate. Actual accuracy may vary based on environment.", } enc := json.NewEncoder(os.Stdout) diff --git a/mothership/cmd/sim/main_test.go b/mothership/cmd/sim/main_test.go index 8bdc17a..73cc14b 100644 --- a/mothership/cmd/sim/main_test.go +++ b/mothership/cmd/sim/main_test.go @@ -464,15 +464,15 @@ func TestDeltaRMSComputation(t *testing.T) { rx := Point{X: 5, Y: 0, Z: 2} tests := []struct { - name string - walker Point - minRMS float64 - maxRMS float64 + name string + walker Point + minRMS float64 + maxRMS float64 }{ { name: "Walker on direct line (zone 1)", walker: Point{X: 2.5, Y: 0, Z: 1.7}, - minRMS: 0.1, // zone 1 should have high deltaRMS + minRMS: 0.1, // zone 1 should have high deltaRMS maxRMS: 0.2, }, { @@ -540,7 +540,7 @@ func TestWalkerBounce(t *testing.T) { ID: 0, Type: WalkerTypeRandomWalk, Position: Point{X: 0.1, Y: 2.5, Z: 1.7}, // Near left wall - Velocity: Point{X: -1.0, Y: 0, Z: 0}, // Moving left + Velocity: Point{X: -1.0, Y: 0, Z: 0}, // Moving left Speed: 1.0, Height: 1.7, } @@ -845,29 +845,29 @@ func TestLineIntersection(t *testing.T) { wantY float64 }{ { - name: "Crossing lines", - p1: Point{X: 0, Y: 0, Z: 0}, - p2: Point{X: 2, Y: 2, Z: 0}, - p3: Point{X: 0, Y: 2, Z: 0}, - p4: Point{X: 2, Y: 0, Z: 0}, + name: "Crossing lines", + p1: Point{X: 0, Y: 0, Z: 0}, + p2: Point{X: 2, Y: 2, Z: 0}, + p3: Point{X: 0, Y: 2, Z: 0}, + p4: Point{X: 2, Y: 0, Z: 0}, wantOK: true, wantX: 1, wantY: 1, }, { - name: "Parallel lines", - p1: Point{X: 0, Y: 0, Z: 0}, - p2: Point{X: 1, Y: 0, Z: 0}, - p3: Point{X: 0, Y: 1, Z: 0}, - p4: Point{X: 1, Y: 1, Z: 0}, + name: "Parallel lines", + p1: Point{X: 0, Y: 0, Z: 0}, + p2: Point{X: 1, Y: 0, Z: 0}, + p3: Point{X: 0, Y: 1, Z: 0}, + p4: Point{X: 1, Y: 1, Z: 0}, wantOK: false, }, { - name: "Non-intersecting segments", - p1: Point{X: 0, Y: 0, Z: 0}, - p2: Point{X: 1, Y: 0, Z: 0}, - p3: Point{X: 2, Y: 0, Z: 0}, - p4: Point{X: 3, Y: 0, Z: 0}, + name: "Non-intersecting segments", + p1: Point{X: 0, Y: 0, Z: 0}, + p2: Point{X: 1, Y: 0, Z: 0}, + p3: Point{X: 2, Y: 0, Z: 0}, + p4: Point{X: 3, Y: 0, Z: 0}, wantOK: false, }, } diff --git a/mothership/cmd/sim/scenario.go b/mothership/cmd/sim/scenario.go index 7f73725..42b7ad2 100644 --- a/mothership/cmd/sim/scenario.go +++ b/mothership/cmd/sim/scenario.go @@ -27,11 +27,11 @@ const ( // ScenarioConfig holds scenario-specific configuration type ScenarioConfig struct { - Type ScenarioType - FallParams FallScenarioParams - OTAParams OTAScenarioParams - StartedAt time.Time - Phase string // for multi-phase scenarios + Type ScenarioType + FallParams FallScenarioParams + OTAParams OTAScenarioParams + StartedAt time.Time + Phase string // for multi-phase scenarios } // FallScenarioParams defines parameters for fall detection scenario @@ -46,12 +46,12 @@ type FallScenarioParams struct { // OTAScenarioParams defines parameters for OTA update scenario type OTAScenarioParams struct { - UpdateAfter time.Duration // Time before OTA starts - FirmwareSize int64 // Size of firmware in bytes - NewVersion string // New firmware version - RebootDelay time.Duration // Delay before rebooting - BootFailDuration time.Duration // How long to simulate boot failure (for rollback test) - SimulateFailure bool // Whether to simulate a boot failure + UpdateAfter time.Duration // Time before OTA starts + FirmwareSize int64 // Size of firmware in bytes + NewVersion string // New firmware version + RebootDelay time.Duration // Delay before rebooting + BootFailDuration time.Duration // How long to simulate boot failure (for rollback test) + SimulateFailure bool // Whether to simulate a boot failure } // FallScenarioState tracks fall scenario state for a walker @@ -160,14 +160,14 @@ func (s *FallScenarioState) StartFall(params FallScenarioParams) { // OTAScenarioState tracks OTA scenario state for a node type OTAScenarioState struct { - Node *VirtualNode - State string // "idle", "downloading", "installing", "rebooting", "updated", "rollback" - CurrentVersion string - DownloadedBytes int64 - DownloadStart time.Time - RebootStart time.Time - FailureStart time.Time - AllNodes []*VirtualNode + Node *VirtualNode + State string // "idle", "downloading", "installing", "rebooting", "updated", "rollback" + CurrentVersion string + DownloadedBytes int64 + DownloadStart time.Time + RebootStart time.Time + FailureStart time.Time + AllNodes []*VirtualNode } // SendOTAStatus sends OTA status message to mothership diff --git a/mothership/cmd/sim/verify.go b/mothership/cmd/sim/verify.go index 95bca1b..840ebe9 100644 --- a/mothership/cmd/sim/verify.go +++ b/mothership/cmd/sim/verify.go @@ -65,8 +65,8 @@ func (w *CSVWriter) WriteRow(walkers []*Walker, nodes []*VirtualNode, walls []Wa fmt.Sprintf("%.3f", walker.Velocity.X), fmt.Sprintf("%.3f", walker.Velocity.Y), fmt.Sprintf("%.3f", walker.Velocity.Z), - "", // link_id — empty for position-only rows - "", // delta_rms — empty for position-only rows + "", // link_id — empty for position-only rows + "", // delta_rms — empty for position-only rows } if err := w.writer.Write(row); err != nil { log.Printf("[SIM] CSV write error: %v", err) diff --git a/mothership/internal/analytics/alert_handler.go b/mothership/internal/analytics/alert_handler.go index 05c7a37..24e69a5 100644 --- a/mothership/internal/analytics/alert_handler.go +++ b/mothership/internal/analytics/alert_handler.go @@ -15,8 +15,8 @@ import ( // NotificationAlertHandler implements AlertHandler using a notification service. type NotificationAlertHandler struct { notifyService NotificationService - httpClient *http.Client - webhookURL string + httpClient *http.Client + webhookURL string escalationURL string } @@ -24,9 +24,9 @@ type NotificationAlertHandler struct { type NotificationService interface { Send(notif Notification) error GenerateFloorPlanThumbnail(width, height int, blobs []struct { - X, Y, Z float64 - Identity string - IsFall bool + X, Y, Z float64 + Identity string + IsFall bool }) ([]byte, error) } @@ -46,7 +46,7 @@ type Notification struct { func NewNotificationAlertHandler(notifyService NotificationService) *NotificationAlertHandler { return &NotificationAlertHandler{ notifyService: notifyService, - httpClient: &http.Client{Timeout: 10 * time.Second}, + httpClient: &http.Client{Timeout: 10 * time.Second}, } } @@ -64,9 +64,9 @@ func (h *NotificationAlertHandler) SetEscalationURL(url string) { func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediate bool) error { // Generate floor plan thumbnail thumbnail, err := h.notifyService.GenerateFloorPlanThumbnail(400, 300, []struct { - X, Y, Z float64 - Identity string - IsFall bool + X, Y, Z float64 + Identity string + IsFall bool }{ { X: event.Position.X, @@ -88,14 +88,14 @@ func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediat Image: thumbnail, ImageType: "image/png", Data: map[string]interface{}{ - "anomaly_id": event.ID, - "anomaly_type": string(event.Type), - "zone_id": event.ZoneID, - "zone_name": event.ZoneName, - "person_id": event.PersonID, - "person_name": event.PersonName, - "timestamp": event.Timestamp.Format(time.RFC3339), - "immediate": immediate, + "anomaly_id": event.ID, + "anomaly_type": string(event.Type), + "zone_id": event.ZoneID, + "zone_name": event.ZoneName, + "person_id": event.PersonID, + "person_name": event.PersonName, + "timestamp": event.Timestamp.Format(time.RFC3339), + "immediate": immediate, }, Timestamp: time.Now(), } @@ -116,20 +116,20 @@ func (h *NotificationAlertHandler) SendWebhook(event events.AnomalyEvent, immedi } payload := map[string]interface{}{ - "anomaly_id": event.ID, - "type": string(event.Type), - "score": event.Score, - "description": event.Description, - "timestamp": event.Timestamp.Format(time.RFC3339), - "zone_id": event.ZoneID, - "zone_name": event.ZoneName, - "person_id": event.PersonID, - "person_name": event.PersonName, - "device_mac": event.DeviceMAC, - "position": event.Position, - "hour_of_week": event.HourOfWeek, + "anomaly_id": event.ID, + "type": string(event.Type), + "score": event.Score, + "description": event.Description, + "timestamp": event.Timestamp.Format(time.RFC3339), + "zone_id": event.ZoneID, + "zone_name": event.ZoneName, + "person_id": event.PersonID, + "person_name": event.PersonName, + "device_mac": event.DeviceMAC, + "position": event.Position, + "hour_of_week": event.HourOfWeek, "expected_occupancy": event.ExpectedOccupancy, - "immediate": immediate, + "immediate": immediate, } body, err := json.Marshal(payload) @@ -165,16 +165,16 @@ func (h *NotificationAlertHandler) SendEscalation(event events.AnomalyEvent) err } payload := map[string]interface{}{ - "anomaly_id": event.ID, - "type": string(event.Type), - "score": event.Score, - "description": event.Description, - "timestamp": event.Timestamp.Format(time.RFC3339), - "zone_id": event.ZoneID, - "zone_name": event.ZoneName, - "person_id": event.PersonID, - "person_name": event.PersonName, - "escalation": true, + "anomaly_id": event.ID, + "type": string(event.Type), + "score": event.Score, + "description": event.Description, + "timestamp": event.Timestamp.Format(time.RFC3339), + "zone_id": event.ZoneID, + "zone_name": event.ZoneName, + "person_id": event.PersonID, + "person_name": event.PersonName, + "escalation": true, } body, err := json.Marshal(payload) diff --git a/mothership/internal/analytics/anomaly.go b/mothership/internal/analytics/anomaly.go index 6c0b9b5..12b39a7 100644 --- a/mothership/internal/analytics/anomaly.go +++ b/mothership/internal/analytics/anomaly.go @@ -22,11 +22,11 @@ import ( // NormalBehaviourSlot represents expected behaviour for a specific hour_of_week and zone. type NormalBehaviourSlot struct { - HourOfWeek int `json:"hour_of_week"` // 0-167 - ZoneID string `json:"zone_id"` - ExpectedOccupancy float64 `json:"expected_occupancy"` // 0.0-1.0, fraction of samples with occupancy - TypicalPersonCount float64 `json:"typical_person_count"` // Mean person count - SampleCount int `json:"sample_count"` + HourOfWeek int `json:"hour_of_week"` // 0-167 + ZoneID string `json:"zone_id"` + ExpectedOccupancy float64 `json:"expected_occupancy"` // 0.0-1.0, fraction of samples with occupancy + TypicalPersonCount float64 `json:"typical_person_count"` // Mean person count + SampleCount int `json:"sample_count"` TypicalBLEDevices map[string]float64 `json:"typical_ble_devices,omitempty"` // MAC -> frequency (0.0-1.0) } @@ -43,51 +43,51 @@ type DwellBehaviourSlot struct { // AnomalyScoreConfig holds configurable thresholds for anomaly scoring. type AnomalyScoreConfig struct { // Unusual hour presence - UnusualHourScore float64 `json:"unusual_hour_score"` // Default: 0.7 - UnusualHourScoreSecurity float64 `json:"unusual_hour_score_security"` // Default: 0.9 - LateNightMultiplier float64 `json:"late_night_multiplier"` // Default: 1.5 (00:00-06:00) + UnusualHourScore float64 `json:"unusual_hour_score"` // Default: 0.7 + UnusualHourScoreSecurity float64 `json:"unusual_hour_score_security"` // Default: 0.9 + LateNightMultiplier float64 `json:"late_night_multiplier"` // Default: 1.5 (00:00-06:00) // Unknown BLE device - UnknownBLEScore float64 `json:"unknown_ble_score"` // Default: 0.5 - UnknownBLEScoreSecurity float64 `json:"unknown_ble_score_security"` // Default: 0.8 - SeenOnceScore float64 `json:"seen_once_score"` // Default: 0.3 - CloseRangeRSSIThreshold int `json:"close_range_rssi_threshold"` // Default: -60 dBm + UnknownBLEScore float64 `json:"unknown_ble_score"` // Default: 0.5 + UnknownBLEScoreSecurity float64 `json:"unknown_ble_score_security"` // Default: 0.8 + SeenOnceScore float64 `json:"seen_once_score"` // Default: 0.3 + CloseRangeRSSIThreshold int `json:"close_range_rssi_threshold"` // Default: -60 dBm // Motion during away - MotionDuringAwayScore float64 `json:"motion_during_away_score"` // Default: 0.95 + MotionDuringAwayScore float64 `json:"motion_during_away_score"` // Default: 0.95 // Unusual dwell duration - UnusualDwellScore float64 `json:"unusual_dwell_score"` // Default: 0.4 - DwellMultiplierThreshold float64 `json:"dwell_multiplier_threshold"` // Default: 5.0 + UnusualDwellScore float64 `json:"unusual_dwell_score"` // Default: 0.4 + DwellMultiplierThreshold float64 `json:"dwell_multiplier_threshold"` // Default: 5.0 // Alert thresholds - AlertThresholdNormal float64 `json:"alert_threshold_normal"` // Default: 0.6 - AlertThresholdSecurity float64 `json:"alert_threshold_security"` // Default: 0.4 + AlertThresholdNormal float64 `json:"alert_threshold_normal"` // Default: 0.6 + AlertThresholdSecurity float64 `json:"alert_threshold_security"` // Default: 0.4 // Auto-away/disarm - AutoAwayDuration time.Duration `json:"auto_away_duration"` // Default: 15 minutes - AutoDisarmRSSIThreshold int `json:"auto_disarm_rssi_threshold"` // Default: -70 dBm - ManualOverrideDuration time.Duration `json:"manual_override_duration"` // Default: 30 minutes + AutoAwayDuration time.Duration `json:"auto_away_duration"` // Default: 15 minutes + AutoDisarmRSSIThreshold int `json:"auto_disarm_rssi_threshold"` // Default: -70 dBm + ManualOverrideDuration time.Duration `json:"manual_override_duration"` // Default: 30 minutes } // DefaultAnomalyScoreConfig returns default configuration. func DefaultAnomalyScoreConfig() AnomalyScoreConfig { return AnomalyScoreConfig{ - UnusualHourScore: 0.7, - UnusualHourScoreSecurity: 0.9, - LateNightMultiplier: 1.5, - UnknownBLEScore: 0.5, - UnknownBLEScoreSecurity: 0.8, - SeenOnceScore: 0.3, - CloseRangeRSSIThreshold: -60, - MotionDuringAwayScore: 0.95, - UnusualDwellScore: 0.4, - DwellMultiplierThreshold: 5.0, - AlertThresholdNormal: 0.6, - AlertThresholdSecurity: 0.4, - AutoAwayDuration: 15 * time.Minute, - AutoDisarmRSSIThreshold: -70, - ManualOverrideDuration: 30 * time.Minute, + UnusualHourScore: 0.7, + UnusualHourScoreSecurity: 0.9, + LateNightMultiplier: 1.5, + UnknownBLEScore: 0.5, + UnknownBLEScoreSecurity: 0.8, + SeenOnceScore: 0.3, + CloseRangeRSSIThreshold: -60, + MotionDuringAwayScore: 0.95, + UnusualDwellScore: 0.4, + DwellMultiplierThreshold: 5.0, + AlertThresholdNormal: 0.6, + AlertThresholdSecurity: 0.4, + AutoAwayDuration: 15 * time.Minute, + AutoDisarmRSSIThreshold: -70, + ManualOverrideDuration: 30 * time.Minute, } } @@ -95,33 +95,33 @@ func DefaultAnomalyScoreConfig() AnomalyScoreConfig { type SecurityMode string const ( - SecurityModeDisarmed SecurityMode = "disarmed" - SecurityModeArmed SecurityMode = "armed" + SecurityModeDisarmed SecurityMode = "disarmed" + SecurityModeArmed SecurityMode = "armed" SecurityModeArmedStay SecurityMode = "armed_stay" // Armed but people are home ) // AutoAwayState tracks state for auto-away functionality. type AutoAwayState struct { - LastMotionTime time.Time `json:"last_motion_time"` - LastPersonCount int `json:"last_person_count"` - AutoAwayTriggered bool `json:"auto_away_triggered"` + LastMotionTime time.Time `json:"last_motion_time"` + LastPersonCount int `json:"last_person_count"` + AutoAwayTriggered bool `json:"auto_away_triggered"` } // AutoDisarmState tracks state for auto-disarm functionality. type AutoDisarmState struct { RegisteredDeviceSeen bool `json:"registered_device_seen"` - SeenDeviceMAC string `json:"seen_device_mac"` - SeenDeviceRSSI int `json:"seen_device_rssi"` - LastSeenTime time.Time `json:"last_seen_time"` + SeenDeviceMAC string `json:"seen_device_mac"` + SeenDeviceRSSI int `json:"seen_device_rssi"` + LastSeenTime time.Time `json:"last_seen_time"` } // AnomalyCooldownConfig holds configuration for anomaly deduplication. type AnomalyCooldownConfig struct { // Cooldown duration per anomaly type+zone combination - UnusualHourCooldown time.Duration `json:"unusual_hour_cooldown"` // Default: 30 minutes - UnknownBLECooldown time.Duration `json:"unknown_ble_cooldown"` // Default: 10 minutes + UnusualHourCooldown time.Duration `json:"unusual_hour_cooldown"` // Default: 30 minutes + UnknownBLECooldown time.Duration `json:"unknown_ble_cooldown"` // Default: 10 minutes MotionDuringAwayCooldown time.Duration `json:"motion_during_away_cooldown"` // Default: 5 minutes - UnusualDwellCooldown time.Duration `json:"unusual_dwell_cooldown"` // Default: 1 hour + UnusualDwellCooldown time.Duration `json:"unusual_dwell_cooldown"` // Default: 1 hour } // DefaultAnomalyCooldownConfig returns default cooldown configuration. @@ -144,21 +144,21 @@ type cooldownKey struct { // Detector detects anomalies based on learned normal behaviour. type Detector struct { - mu sync.RWMutex - db *sql.DB - config AnomalyScoreConfig + mu sync.RWMutex + db *sql.DB + config AnomalyScoreConfig cooldownConfig AnomalyCooldownConfig // Normal behaviour model (loaded from DB) - behaviourSlots map[string]*NormalBehaviourSlot // key: "hour-zone" - dwellSlots map[string]*DwellBehaviourSlot // key: "hour-zone-person" + behaviourSlots map[string]*NormalBehaviourSlot // key: "hour-zone" + dwellSlots map[string]*DwellBehaviourSlot // key: "hour-zone-person" // Active anomaly tracking - activeAnomalies map[string]*events.AnomalyEvent // id -> event - anomalyHistory []*events.AnomalyEvent + activeAnomalies map[string]*events.AnomalyEvent // id -> event + anomalyHistory []*events.AnomalyEvent // Pending alert timers - pendingAlerts map[string]*alertTimerState + pendingAlerts map[string]*alertTimerState // Anomaly cooldown tracking for deduplication anomalyCooldowns map[cooldownKey]time.Time // key -> last triggered time @@ -169,29 +169,29 @@ type Detector struct { modelReadyAt time.Time // Registered devices and people - registeredDevices map[string]bool // MAC -> registered - registeredPeople map[string]string // person_id -> name + registeredDevices map[string]bool // MAC -> registered + registeredPeople map[string]string // person_id -> name deviceFirstSeen map[string]time.Time // MAC -> first seen time // Security mode state - securityMode SecurityMode - autoAwayState AutoAwayState - autoDisarmState AutoDisarmState + securityMode SecurityMode + autoAwayState AutoAwayState + autoDisarmState AutoDisarmState manualOverrideUntil time.Time // Manual mode override expiry // Providers - zoneProvider ZoneProvider - personProvider PersonProvider - deviceProvider DeviceProvider - positionProvider PositionProvider - alertHandler AlertHandler + zoneProvider ZoneProvider + personProvider PersonProvider + deviceProvider DeviceProvider + positionProvider PositionProvider + alertHandler AlertHandler // Feedback store for accuracy tracking - feedbackStore *learning.FeedbackStore + feedbackStore *learning.FeedbackStore // Callbacks - onAnomaly func(event events.AnomalyEvent) - onModeChange func(event events.SystemModeChangeEvent) + onAnomaly func(event events.AnomalyEvent) + onModeChange func(event events.SystemModeChangeEvent) onSecurityModeChange func(oldMode, newMode SecurityMode, reason string) } @@ -247,14 +247,14 @@ func NewDetector(dbPath string, config AnomalyScoreConfig) (*Detector, error) { db.SetMaxOpenConns(1) d := &Detector{ - db: db, - config: config, - cooldownConfig: DefaultAnomalyCooldownConfig(), - behaviourSlots: make(map[string]*NormalBehaviourSlot), - dwellSlots: make(map[string]*DwellBehaviourSlot), - activeAnomalies: make(map[string]*events.AnomalyEvent), - pendingAlerts: make(map[string]*alertTimerState), - anomalyCooldowns: make(map[cooldownKey]time.Time), + db: db, + config: config, + cooldownConfig: DefaultAnomalyCooldownConfig(), + behaviourSlots: make(map[string]*NormalBehaviourSlot), + dwellSlots: make(map[string]*DwellBehaviourSlot), + activeAnomalies: make(map[string]*events.AnomalyEvent), + pendingAlerts: make(map[string]*alertTimerState), + anomalyCooldowns: make(map[cooldownKey]time.Time), registeredDevices: make(map[string]bool), registeredPeople: make(map[string]string), deviceFirstSeen: make(map[string]time.Time), @@ -1074,17 +1074,17 @@ func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration t } event := events.AnomalyEvent{ - ID: uuid.New().String(), - Type: events.AnomalyUnusualDwell, - Score: score, - Description: fmt.Sprintf("%s in %s for longer than usual (%.0f minutes)", personName, zoneName, dwellDuration.Minutes()), - Timestamp: now, - ZoneID: zoneID, - ZoneName: zoneName, - PersonID: personID, - PersonName: personName, - DwellDuration: dwellDuration, - ExpectedDwell: slot.MeanDwellDuration, + ID: uuid.New().String(), + Type: events.AnomalyUnusualDwell, + Score: score, + Description: fmt.Sprintf("%s in %s for longer than usual (%.0f minutes)", personName, zoneName, dwellDuration.Minutes()), + Timestamp: now, + ZoneID: zoneID, + ZoneName: zoneName, + PersonID: personID, + PersonName: personName, + DwellDuration: dwellDuration, + ExpectedDwell: slot.MeanDwellDuration, } // Mark cooldown @@ -1208,7 +1208,7 @@ func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bo // Security mode: all alerts fire immediately if d.alertHandler != nil { d.alertHandler.SendWebhook(*event, true) //nolint:errcheck - d.alertHandler.SendEscalation(*event) //nolint:errcheck + d.alertHandler.SendEscalation(*event) //nolint:errcheck } event.AlertSent = true event.WebhookSent = true @@ -1267,7 +1267,7 @@ func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bo } func (d *Detector) updateAnomalyAlertState(event *events.AnomalyEvent) { - _, _ = d.db.Exec(` + _, _ = d.db.Exec(` UPDATE anomaly_events SET alert_sent = ?, alert_sent_at = ?, webhook_sent = ?, webhook_sent_at = ?, @@ -1463,7 +1463,7 @@ func (d *Detector) UpdateBehaviourModel() error { // Upsert to database devicesJSON, _ := jsonMarshal(slot.TypicalBLEDevices) - _, _ = d.db.Exec(` + _, _ = d.db.Exec(` INSERT INTO behaviour_slots (hour_of_week, zone_id, expected_occupancy, typical_person_count, sample_count, typical_ble_devices) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(hour_of_week, zone_id) DO UPDATE SET @@ -1502,7 +1502,7 @@ func (d *Detector) UpdateBehaviourModel() error { slot.MeanDwellDuration = time.Duration(meanNS) slot.StdDwellDuration = time.Duration(stdNS) - _, _ = d.db.Exec(` + _, _ = d.db.Exec(` INSERT INTO dwell_slots (hour_of_week, zone_id, person_id, mean_dwell_ns, std_dwell_ns, sample_count) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(hour_of_week, zone_id, person_id) DO UPDATE SET diff --git a/mothership/internal/analytics/handler.go b/mothership/internal/analytics/handler.go index 4621292..89f4e68 100644 --- a/mothership/internal/analytics/handler.go +++ b/mothership/internal/analytics/handler.go @@ -145,6 +145,7 @@ func (h *AnomalyHandler) RegisterRoutes(r chi.Router) { // handleGetAnomalies returns anomalies filtered by the `since` query parameter. // Query params: // - since: duration string (e.g. "24h", "7d", "1h"). Default "24h". +// // Uses DB-backed QueryAnomalyEvents so results survive server restarts. func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Request) { if h.detector == nil { @@ -259,9 +260,9 @@ func (h *AnomalyHandler) handleGetLearningProgress(w http.ResponseWriter, r *htt ready := h.detector.IsModelReady() response := map[string]interface{}{ - "progress": progress, - "model_ready": ready, - "days_learned": int(progress * 7), + "progress": progress, + "model_ready": ready, + "days_learned": int(progress * 7), "days_remaining": int((1 - progress) * 7), } writeJSON(w, response) diff --git a/mothership/internal/analytics/patterns.go b/mothership/internal/analytics/patterns.go index 7b8dc69..1d70fa8 100644 --- a/mothership/internal/analytics/patterns.go +++ b/mothership/internal/analytics/patterns.go @@ -35,8 +35,8 @@ const ( // PatternSlot represents a single (zone_id, hour_of_day, day_of_week) statistical slot. type PatternSlot struct { ZoneID string `json:"zone_id"` - HourOfDay int `json:"hour_of_day"` // 0-23 - DayOfWeek int `json:"day_of_week"` // 0-6 (0=Sunday) + HourOfDay int `json:"hour_of_day"` // 0-23 + DayOfWeek int `json:"day_of_week"` // 0-6 (0=Sunday) MeanCount float64 `json:"mean_count"` Variance float64 `json:"variance"` SampleCount int `json:"sample_count"` diff --git a/mothership/internal/analytics/patterns_test.go b/mothership/internal/analytics/patterns_test.go index fb16504..f8ecebf 100644 --- a/mothership/internal/analytics/patterns_test.go +++ b/mothership/internal/analytics/patterns_test.go @@ -491,18 +491,18 @@ func TestPatternLearner_SurvivesRestart(t *testing.T) { func TestPatternLearner_AlertThresholds(t *testing.T) { tests := []struct { - name string + name string observations []int - testCount int - wantAlert bool - wantWarning bool + testCount int + wantAlert bool + wantWarning bool }{ { - name: "normal observation at mean", + name: "normal observation at mean", observations: makeConst(2, 50), - testCount: 2, - wantAlert: false, - wantWarning: false, + testCount: 2, + wantAlert: false, + wantWarning: false, }, } diff --git a/mothership/internal/apdetector/detector.go b/mothership/internal/apdetector/detector.go index 65b8a56..17924bd 100644 --- a/mothership/internal/apdetector/detector.go +++ b/mothership/internal/apdetector/detector.go @@ -30,22 +30,22 @@ type BSSIDReport struct { // APInfo holds information about a detected AP type APInfo struct { - BSSID string - Channel int - Manufacturer string - ReportCount int - TotalNodes int - AgreementPct float64 - LastUpdated time.Time + BSSID string + Channel int + Manufacturer string + ReportCount int + TotalNodes int + AgreementPct float64 + LastUpdated time.Time } // Detector manages AP BSSID detection and virtual node creation type Detector struct { - db *sql.DB - mu sync.RWMutex - reports map[string][]BSSIDReport // keyed by normalized BSSID - currentAP *APInfo - subscribers []chan APInfo + db *sql.DB + mu sync.RWMutex + reports map[string][]BSSIDReport // keyed by normalized BSSID + currentAP *APInfo + subscribers []chan APInfo } // NewDetector creates a new AP detector diff --git a/mothership/internal/api/alerts.go b/mothership/internal/api/alerts.go index 1c64ae5..f5b6e91 100644 --- a/mothership/internal/api/alerts.go +++ b/mothership/internal/api/alerts.go @@ -16,29 +16,29 @@ import ( // AlertsHandler manages the unified alerts API. type AlertsHandler struct { - mu sync.RWMutex - fallDetector *falldetect.Detector - anomalyDetector *analytics.Detector - fleetRegistry *fleet.Registry + mu sync.RWMutex + fallDetector *falldetect.Detector + anomalyDetector *analytics.Detector + fleetRegistry *fleet.Registry } // Alert represents a unified alert from any source. type Alert struct { - ID string `json:"id"` - Type string `json:"type"` // "fall", "anomaly", "node_offline" - Severity string `json:"severity"` // "critical", "warning", "info" - Title string `json:"title"` - Message string `json:"message"` - Zone string `json:"zone,omitempty"` - Person string `json:"person,omitempty"` + ID string `json:"id"` + Type string `json:"type"` // "fall", "anomaly", "node_offline" + Severity string `json:"severity"` // "critical", "warning", "info" + Title string `json:"title"` + Message string `json:"message"` + Zone string `json:"zone,omitempty"` + Person string `json:"person,omitempty"` Timestamp int64 `json:"timestamp_ms"` - Data any `json:"data,omitempty"` // Type-specific data + Data any `json:"data,omitempty"` // Type-specific data } // ActiveAlertsResponse is the response for GET /api/alerts/active. type ActiveAlertsResponse struct { Alerts []Alert `json:"alerts"` - Count int `json:"count"` + Count int `json:"count"` } // NewAlertsHandler creates a new alerts handler. @@ -316,4 +316,3 @@ func (h *AlertsHandler) handleAcknowledgeAnomaly(w http.ResponseWriter, r *http. // Override the path parameter to use the prefixed version // This is handled by the unified handler } - diff --git a/mothership/internal/api/analytics.go b/mothership/internal/api/analytics.go index 312fe93..58576f3 100644 --- a/mothership/internal/api/analytics.go +++ b/mothership/internal/api/analytics.go @@ -16,7 +16,7 @@ import ( // AnalyticsHandler manages the crowd flow analytics API endpoints. type AnalyticsHandler struct { flowAccumulator *analytics.FlowAccumulator - db *sql.DB + db *sql.DB } // NewAnalyticsHandler creates a new analytics handler. @@ -39,7 +39,7 @@ func NewAnalyticsHandler(db *sql.DB, cellSizeM float64) *AnalyticsHandler { return &AnalyticsHandler{ flowAccumulator: flowAcc, - db: db, + db: db, } } diff --git a/mothership/internal/api/baseline.go b/mothership/internal/api/baseline.go index bda5245..efbba1d 100644 --- a/mothership/internal/api/baseline.go +++ b/mothership/internal/api/baseline.go @@ -35,8 +35,8 @@ func (h *BaselineHandler) RegisterRoutes(r chi.Router) { type BaselineEntry struct { LinkID string `json:"link_id"` SnapshotTime int64 `json:"snapshot_time_ms"` // Unix milliseconds - Confidence float64 `json:"confidence"` // 0.0–1.0 - NSub int `json:"n_sub"` // Number of subcarriers + Confidence float64 `json:"confidence"` // 0.0–1.0 + NSub int `json:"n_sub"` // Number of subcarriers } // listBaselines handles GET /api/baseline diff --git a/mothership/internal/api/baseline_test.go b/mothership/internal/api/baseline_test.go index 77a1bc0..520ac0e 100644 --- a/mothership/internal/api/baseline_test.go +++ b/mothership/internal/api/baseline_test.go @@ -51,7 +51,7 @@ func TestBaselineHandler_ListBaselines(t *testing.T) { capturedAt int64 confidence float64 nSub int - amplitude []float32 + amplitude []float32 phase []float32 }{ {"AA:BB:CC:DD:EE:FF", now - 10000, 0.8, 64, []float32{1.0, 2.0}, []float32{0.1, 0.2}}, diff --git a/mothership/internal/api/ble_test.go b/mothership/internal/api/ble_test.go index a0b8a6e..e119dd7 100644 --- a/mothership/internal/api/ble_test.go +++ b/mothership/internal/api/ble_test.go @@ -245,24 +245,24 @@ func TestUpdateBLEDevice(t *testing.T) { wantPerson string }{ { - name: "update label only", - mac: "AA:BB:CC:DD:EE:01", - body: `{"label": "Alice's iPhone"}`, + name: "update label only", + mac: "AA:BB:CC:DD:EE:01", + body: `{"label": "Alice's iPhone"}`, wantStatus: http.StatusOK, - wantLabel: "Alice's iPhone", + wantLabel: "Alice's iPhone", }, { - name: "update device type", - mac: "AA:BB:CC:DD:EE:02", - body: `{"device_type": "apple_phone"}`, + name: "update device type", + mac: "AA:BB:CC:DD:EE:02", + body: `{"device_type": "apple_phone"}`, wantStatus: http.StatusOK, }, { - name: "update all fields", - mac: "AA:BB:CC:DD:EE:03", - body: `{"label": "Bob's Phone", "device_type": "samsung"}`, + name: "update all fields", + mac: "AA:BB:CC:DD:EE:03", + body: `{"label": "Bob's Phone", "device_type": "samsung"}`, wantStatus: http.StatusOK, - wantLabel: "Bob's Phone", + wantLabel: "Bob's Phone", }, } @@ -612,7 +612,7 @@ func TestListPeople(t *testing.T) { // Create people registry.CreatePerson("Alice", "#ff0000") //nolint:errcheck - registry.CreatePerson("Bob", "#0000ff") //nolint:errcheck + registry.CreatePerson("Bob", "#0000ff") //nolint:errcheck r := setupBLERouter(h) req := httptest.NewRequest("GET", "/api/people", nil) diff --git a/mothership/internal/api/briefing.go b/mothership/internal/api/briefing.go index c6d2cbe..624a512 100644 --- a/mothership/internal/api/briefing.go +++ b/mothership/internal/api/briefing.go @@ -10,19 +10,19 @@ import ( "time" "github.com/go-chi/chi/v5" - _ "modernc.org/sqlite" "github.com/spaxel/mothership/internal/briefing" + _ "modernc.org/sqlite" ) // BriefingHandler manages morning briefing REST endpoints. type BriefingHandler struct { - generator *briefing.Generator - db *sql.DB - notifyService briefing.NotifyService - zoneProvider briefing.ZoneProvider - personProvider briefing.PersonProvider + generator *briefing.Generator + db *sql.DB + notifyService briefing.NotifyService + zoneProvider briefing.ZoneProvider + personProvider briefing.PersonProvider predictionProvider briefing.PredictionProvider - healthProvider briefing.HealthProvider + healthProvider briefing.HealthProvider } // NewBriefingHandler creates a new briefing handler. @@ -262,9 +262,9 @@ func (h *BriefingHandler) handleTestNotification(w http.ResponseWriter, r *http. if h.notifyService != nil { notif := briefing.Notification{ Title: "Morning Briefing (Test)", - Body: b.Content, + Body: b.Content, Priority: 1, - Tags: []string{"briefing", "test"}, + Tags: []string{"briefing", "test"}, Timestamp: time.Now(), } if err := h.notifyService.Send(notif); err != nil { diff --git a/mothership/internal/api/diurnal.go b/mothership/internal/api/diurnal.go index 08ea95f..89b4991 100644 --- a/mothership/internal/api/diurnal.go +++ b/mothership/internal/api/diurnal.go @@ -123,4 +123,3 @@ func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, response) } - diff --git a/mothership/internal/api/events.go b/mothership/internal/api/events.go index 0cfde7e..1b14528 100644 --- a/mothership/internal/api/events.go +++ b/mothership/internal/api/events.go @@ -25,10 +25,10 @@ const ( // EventsHandler manages the events timeline. type EventsHandler struct { - mu sync.RWMutex - db *sql.DB - hub DashboardHub - ownsDB bool + mu sync.RWMutex + db *sql.DB + hub DashboardHub + ownsDB bool feedbackHandler any // FeedbackHandler for POST /api/events/{id}/feedback } @@ -39,14 +39,14 @@ type DashboardHub interface { // Event represents a timeline event. type Event struct { - ID int64 `json:"id"` - Timestamp int64 `json:"timestamp_ms"` - Type string `json:"type"` - Zone string `json:"zone,omitempty"` - Person string `json:"person,omitempty"` - BlobID int `json:"blob_id,omitempty"` - DetailJSON string `json:"detail_json,omitempty"` - Severity string `json:"severity"` + ID int64 `json:"id"` + Timestamp int64 `json:"timestamp_ms"` + Type string `json:"type"` + Zone string `json:"zone,omitempty"` + Person string `json:"person,omitempty"` + BlobID int `json:"blob_id,omitempty"` + DetailJSON string `json:"detail_json,omitempty"` + Severity string `json:"severity"` } // LogEvent logs a new event to the database and broadcasts it. @@ -574,8 +574,7 @@ func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request fmt.Sprintf(`{"event_id":%d,"type":"%s","blob_id":%d}`, eventID, req.Type, req.BlobID), "info") writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, + "ok": true, "message": "Feedback recorded", }) } - diff --git a/mothership/internal/api/feedback.go b/mothership/internal/api/feedback.go index 1e6cab0..97006d5 100644 --- a/mothership/internal/api/feedback.go +++ b/mothership/internal/api/feedback.go @@ -28,10 +28,10 @@ type FeedbackRequest struct { // FeedbackHandler handles simple feedback submissions from the UI. type FeedbackHandler struct { - eventsHandler *EventsHandler - learningHandler any // Learning handler with ProcessFeedback method + eventsHandler *EventsHandler + learningHandler any // Learning handler with ProcessFeedback method explainabilityHandler *explainability.Handler - diagnosticEngine *diagnostics.DiagnosticEngine + diagnosticEngine *diagnostics.DiagnosticEngine } // NewFeedbackHandler creates a new feedback handler. @@ -153,7 +153,7 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re // Return success response with inline message response := map[string]interface{}{ - "ok": true, + "ok": true, "message": "Feedback recorded", } @@ -179,12 +179,12 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re contributingLinksData := make([]map[string]interface{}, 0, len(exp.ContributingLinks)) for _, link := range exp.ContributingLinks { linkData := map[string]interface{}{ - "link_id": link.LinkID, - "node_mac": link.NodeMAC, - "peer_mac": link.PeerMAC, - "delta_rms": link.DeltaRMS, - "zone_number": link.ZoneNumber, - "weight": link.Weight, + "link_id": link.LinkID, + "node_mac": link.NodeMAC, + "peer_mac": link.PeerMAC, + "delta_rms": link.DeltaRMS, + "zone_number": link.ZoneNumber, + "weight": link.Weight, "contributing": link.Contributing, } contributingLinksData = append(contributingLinksData, linkData) @@ -307,7 +307,7 @@ func (h *FeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request, // Return success response writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, + "ok": true, "message": "Feedback recorded", }) } diff --git a/mothership/internal/api/guided.go b/mothership/internal/api/guided.go index 473b84f..d2d2346 100644 --- a/mothership/internal/api/guided.go +++ b/mothership/internal/api/guided.go @@ -23,8 +23,8 @@ type GuidedManager interface { // GuidedHandler provides endpoints for proactive contextual help. type GuidedHandler struct { guidedMgr GuidedManager - zonesHandler any - nodesHandler any + zonesHandler any + nodesHandler any diagnosticsHandler DiagnosticsHandler } @@ -103,10 +103,10 @@ func (h *GuidedHandler) handleGetLinkDiagnostics(w http.ResponseWriter, r *http. health := h.getCurrentLinkHealth(linkID) response := map[string]interface{}{ - "link_id": linkID, + "link_id": linkID, "diagnosis": diagnosis, "diagnoses": diagnoses, - "health": health, + "health": health, } writeJSON(w, http.StatusOK, response) @@ -132,11 +132,11 @@ func (h *GuidedHandler) getCurrentLinkHealth(linkID string) map[string]interface // Build health map with current metrics health := map[string]interface{}{ - "packet_rate": 20.0, // Default expected rate - "snr": 0.5, // Default SNR - "phase_stability": 0.5, // Default stability - "drift_rate": 0.0, // No drift - "composite_score": mostRecent.ConfidenceScore, + "packet_rate": 20.0, // Default expected rate + "snr": 0.5, // Default SNR + "phase_stability": 0.5, // Default stability + "drift_rate": 0.0, // No drift + "composite_score": mostRecent.ConfidenceScore, } // If the diagnosis has specific rule info, we can infer health metrics @@ -233,10 +233,10 @@ func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http var req struct { FeedbackType string `json:"feedback_type"` // "incorrect" or "correct" Links []struct { - LinkID string `json:"link_id"` + LinkID string `json:"link_id"` DeltaRMS float64 `json:"delta_rms"` } `json:"links,omitempty"` - ZoneID *int `json:"zone_id,omitempty"` + ZoneID *int `json:"zone_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -278,9 +278,9 @@ func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http // handleCalibrationComplete reports calibration completion and triggers reinforcement. func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http.Request) { var req struct { - ZoneID int `json:"zone_id"` - QualityBefore float64 `json:"quality_before"` - QualityAfter float64 `json:"quality_after"` + ZoneID int `json:"zone_id"` + QualityBefore float64 `json:"quality_before"` + QualityAfter float64 `json:"quality_after"` LinksCalibrated int `json:"links_calibrated"` } @@ -298,12 +298,12 @@ func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http improvementPct := int(improvement) response := map[string]interface{}{ - "type": "calibration_complete", - "title": "Re-baseline complete", - "message": "Detection quality in this zone has improved.", - "improvement": improvementPct, + "type": "calibration_complete", + "title": "Re-baseline complete", + "message": "Detection quality in this zone has improved.", + "improvement": improvementPct, "quality_after": req.QualityAfter, - "links": req.LinksCalibrated, + "links": req.LinksCalibrated, } // Add encouraging message based on improvement @@ -377,12 +377,12 @@ func (h *GuidedHandler) handleGetNodeTroubleshoot(w http.ResponseWriter, r *http } response := map[string]interface{}{ - "mac": mac, - "name": nodeName, - "role": nodeRole, - "offline_minutes": int(offlineDuration), - "troubleshooting": steps, - "escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.", + "mac": mac, + "name": nodeName, + "role": nodeRole, + "offline_minutes": int(offlineDuration), + "troubleshooting": steps, + "escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.", } writeJSON(w, http.StatusOK, response) diff --git a/mothership/internal/api/integrations.go b/mothership/internal/api/integrations.go index 2c0858d..4bb6f51 100644 --- a/mothership/internal/api/integrations.go +++ b/mothership/internal/api/integrations.go @@ -15,8 +15,8 @@ import ( // IntegrationSettingsHandler manages home automation integration settings. type IntegrationSettingsHandler struct { - mu sync.RWMutex - db *sql.DB + mu sync.RWMutex + db *sql.DB // MQTT configuration (managed via settings table) mqttClient MQTTClient @@ -119,25 +119,25 @@ func (h *IntegrationSettingsHandler) RegisterRoutes(r chi.Router) { // integrationSettingsResponse is the response for integration settings. type integrationSettingsResponse struct { - MQTT *mqttConfig `json:"mqtt,omitempty"` - Webhook *webhookConfig `json:"webhook,omitempty"` + MQTT *mqttConfig `json:"mqtt,omitempty"` + Webhook *webhookConfig `json:"webhook,omitempty"` } // mqttConfig holds MQTT configuration. type mqttConfig struct { Broker string `json:"broker,omitempty"` // e.g., "tcp://homeassistant.local:1883" - Username string `json:"username,omitempty"` // MQTT username - Password string `json:"password,omitempty"` // MQTT password (write-only, never returned) + Username string `json:"username,omitempty"` // MQTT username + Password string `json:"password,omitempty"` // MQTT password (write-only, never returned) TLS bool `json:"tls,omitempty"` // Whether to use TLS DiscoveryPrefix string `json:"discovery_prefix,omitempty"` // Home Assistant discovery prefix - Connected bool `json:"connected"` // Connection status - MothershipID string `json:"mothership_id,omitempty"` // Unique ID + Connected bool `json:"connected"` // Connection status + MothershipID string `json:"mothership_id,omitempty"` // Unique ID } // webhookConfig holds system webhook configuration. type webhookConfig struct { - URL string `json:"url,omitempty"` // Webhook URL - Enabled bool `json:"enabled"` // Whether webhook is enabled + URL string `json:"url,omitempty"` // Webhook URL + Enabled bool `json:"enabled"` // Whether webhook is enabled } // integrationSettingsRequest is the request body for updating integration settings. diff --git a/mothership/internal/api/localization.go b/mothership/internal/api/localization.go index abcfc9a..8ae29a5 100644 --- a/mothership/internal/api/localization.go +++ b/mothership/internal/api/localization.go @@ -14,10 +14,10 @@ import ( // LocalizationHandler manages self-improving localization API endpoints. type LocalizationHandler struct { - groundTruthStore *localization.GroundTruthStore - spatialWeightLearner *localization.SpatialWeightLearner - weightLearner *localization.WeightLearner - weightStore *localization.WeightStore + groundTruthStore *localization.GroundTruthStore + spatialWeightLearner *localization.SpatialWeightLearner + weightLearner *localization.WeightLearner + weightStore *localization.WeightStore selfImprovingLocalizer *localization.SelfImprovingLocalizer } @@ -30,10 +30,10 @@ func NewLocalizationHandler( sil *localization.SelfImprovingLocalizer, ) *LocalizationHandler { return &LocalizationHandler{ - groundTruthStore: gtStore, - spatialWeightLearner: swLearner, - weightLearner: wLearner, - weightStore: wStore, + groundTruthStore: gtStore, + spatialWeightLearner: swLearner, + weightLearner: wLearner, + weightStore: wStore, selfImprovingLocalizer: sil, } } @@ -290,10 +290,10 @@ func (h *LocalizationHandler) getGroundTruthStats(w http.ResponseWriter, r *http } writeJSON(w, http.StatusOK, map[string]interface{}{ - "total_samples": total, - "today_samples": today, - "by_person": byPerson, - "zone_counts": zoneCountsStr, + "total_samples": total, + "today_samples": today, + "by_person": byPerson, + "zone_counts": zoneCountsStr, }) } @@ -433,7 +433,7 @@ func (h *LocalizationHandler) getSelfImprovingStatus(w http.ResponseWriter, r *h } writeJSON(w, http.StatusOK, map[string]interface{}{ - "learning_progress": progress, + "learning_progress": progress, "learned_weights": weights, "improvement_stats": improvementStats, "improvement_history": improvementHistory, @@ -457,7 +457,7 @@ func (h *LocalizationHandler) processLearning(w http.ResponseWriter, r *http.Req h.weightLearner.RecordErrorSnapshot() writeJSON(w, http.StatusOK, map[string]string{ - "status": "learning_processed", - "timestamp": time.Now().Format(time.RFC3339), + "status": "learning_processed", + "timestamp": time.Now().Format(time.RFC3339), }) } diff --git a/mothership/internal/api/localization_test.go b/mothership/internal/api/localization_test.go index 66af2c8..2704775 100644 --- a/mothership/internal/api/localization_test.go +++ b/mothership/internal/api/localization_test.go @@ -408,10 +408,10 @@ func TestLocalizationHandler_getGroundTruthSamples(t *testing.T) { // Add some test samples for i := 0; i < 5; i++ { sample := localization.GroundTruthSample{ - Timestamp: time.Now().Add(-time.Duration(i) * time.Minute), - PersonID: "test-person", - BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0}, - BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0}, + Timestamp: time.Now().Add(-time.Duration(i) * time.Minute), + PersonID: "test-person", + BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0}, + BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0}, PositionError: float64(i) * 0.1, PerLinkDeltas: map[string]float64{"link1": 0.5}, PerLinkHealth: map[string]float64{"link1": 0.9}, diff --git a/mothership/internal/api/notification_settings.go b/mothership/internal/api/notification_settings.go index dca8c00..47bcf83 100644 --- a/mothership/internal/api/notification_settings.go +++ b/mothership/internal/api/notification_settings.go @@ -91,9 +91,9 @@ type notificationSettingsResponse struct { // Quiet hours QuietHoursEnabled bool `json:"quiet_hours_enabled"` - QuietHoursStart string `json:"quiet_hours_start"` // HH:MM format - QuietHoursEnd string `json:"quiet_hours_end"` // HH:MM format - QuietHoursDays int `json:"quiet_hours_days"` // Bitmask: 0x7F = all days (Sun=1, Mon=2, ..., Sat=64) + QuietHoursStart string `json:"quiet_hours_start"` // HH:MM format + QuietHoursEnd string `json:"quiet_hours_end"` // HH:MM format + QuietHoursDays int `json:"quiet_hours_days"` // Bitmask: 0x7F = all days (Sun=1, Mon=2, ..., Sat=64) // Morning digest MorningDigestEnabled bool `json:"morning_digest_enabled"` @@ -237,13 +237,13 @@ func (h *NotificationSettingsHandler) getSettings() (*notificationSettingsRespon response := ¬ificationSettingsResponse{ // Set defaults - ChannelType: "none", - ChannelConfig: make(map[string]interface{}), - QuietHoursDays: 0x7F, // All days - MorningDigestTime: "07:00", - SmartBatchingEnabled: true, - SmartBatchingWindow: 30, - EventTypes: getDefaultEventTypes(), + ChannelType: "none", + ChannelConfig: make(map[string]interface{}), + QuietHoursDays: 0x7F, // All days + MorningDigestTime: "07:00", + SmartBatchingEnabled: true, + SmartBatchingWindow: 30, + EventTypes: getDefaultEventTypes(), } // Get channel type @@ -547,4 +547,3 @@ func (e *NotificationValidationError) Error() string { } return e.Reason } - diff --git a/mothership/internal/api/notification_settings_test.go b/mothership/internal/api/notification_settings_test.go index 1625f4f..a1b7e4e 100644 --- a/mothership/internal/api/notification_settings_test.go +++ b/mothership/internal/api/notification_settings_test.go @@ -349,10 +349,10 @@ func TestNotificationSettingsValidation(t *testing.T) { t.Run("validateEventTypes - valid types", func(t *testing.T) { validTypes := map[string]bool{ - "zone_enter": true, - "zone_leave": true, - "fall_detected": true, - "anomaly_alert": true, + "zone_enter": true, + "zone_leave": true, + "fall_detected": true, + "anomaly_alert": true, } if err := validateEventTypes(validTypes); err != nil { t.Errorf("Valid event types should pass validation: %v", err) diff --git a/mothership/internal/api/notifications.go b/mothership/internal/api/notifications.go index 08704d6..cf57c33 100644 --- a/mothership/internal/api/notifications.go +++ b/mothership/internal/api/notifications.go @@ -371,9 +371,9 @@ func (n *NotificationsHandler) handleSetConfig(w http.ResponseWriter, r *http.Re // testNotificationRequest is the request body for sending a test notification. type testNotificationRequest struct { - ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt - Title string `json:"title"` // Custom title (optional) - Body string `json:"body"` // Custom body (optional) + ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt + Title string `json:"title"` // Custom title (optional) + Body string `json:"body"` // Custom body (optional) Data map[string]interface{} `json:"data,omitempty"` // Additional data (optional) } @@ -463,13 +463,13 @@ func (n *NotificationsHandler) handlePreview(w http.ResponseWriter, r *http.Requ // Define test person person := render.Person{ - Name: personName, - X: 2.0, - Y: 1.5, - Z: 1.0, - Color: "#4488ff", + Name: personName, + X: 2.0, + Y: 1.5, + Z: 1.0, + Color: "#4488ff", Confidence: 0.85, - IsFall: false, + IsFall: false, } var pngData []byte diff --git a/mothership/internal/api/prediction.go b/mothership/internal/api/prediction.go index 5ddf0bb..b73efbe 100644 --- a/mothership/internal/api/prediction.go +++ b/mothership/internal/api/prediction.go @@ -13,12 +13,12 @@ import ( // PredictionHandler manages prediction API endpoints. type PredictionHandler struct { - predictor *prediction.Predictor - history *prediction.HistoryUpdater - accuracyTracker *prediction.AccuracyTracker + predictor *prediction.Predictor + history *prediction.HistoryUpdater + accuracyTracker *prediction.AccuracyTracker horizonPredictor *prediction.HorizonPredictor - zoneProvider ZoneProvider - personProvider PersonProvider + zoneProvider ZoneProvider + personProvider PersonProvider } // ZoneProvider provides zone information. @@ -145,9 +145,9 @@ func (h *PredictionHandler) getStats(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]interface{}{ - "transition_count": count, - "data_age_days": dataAge.Hours() / 24, - "minimum_data_age": prediction.MinimumDataAge.Hours() / 24, + "transition_count": count, + "data_age_days": dataAge.Hours() / 24, + "minimum_data_age": prediction.MinimumDataAge.Hours() / 24, "has_minimum_data": dataAge >= prediction.MinimumDataAge, }) } @@ -280,9 +280,9 @@ func (h *PredictionHandler) getZonePattern(w http.ResponseWriter, r *http.Reques if pattern == nil { writeJSON(w, http.StatusOK, map[string]interface{}{ - "zone_id": zoneID, + "zone_id": zoneID, "hour_of_week": hourOfWeek, - "message": "no pattern data for this zone/time", + "message": "no pattern data for this zone/time", }) return } diff --git a/mothership/internal/api/replay.go b/mothership/internal/api/replay.go index 1c2dd64..b1095ca 100644 --- a/mothership/internal/api/replay.go +++ b/mothership/internal/api/replay.go @@ -21,9 +21,9 @@ type ReplayHandler struct { worker *replay.Worker sessions map[string]*_replaySession nextID int - activeSessionID string // Currently active session for dashboard control + activeSessionID string // Currently active session for dashboard control settingsHandler SettingsPersister // For ApplyToLive functionality - replayPath string // Path to the replay binary file + replayPath string // Path to the replay binary file } // SettingsPersister is the interface for persisting replay parameters to live settings. @@ -60,7 +60,7 @@ func NewReplayHandler(store replay.FrameReader) (*ReplayHandler, error) { return &ReplayHandler{ worker: worker, sessions: make(map[string]*_replaySession), - nextID: 1, + nextID: 1, }, nil } @@ -141,12 +141,12 @@ func (h *ReplayHandler) RegisterRoutes(r chi.Router) { // replayInfo represents the response from GET /api/replay/sessions. 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"` + 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"` } @@ -667,15 +667,15 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request) // Build response with session state and blobs response := map[string]interface{}{ - "session_id": sessionID, - "current_ms": session.CurrentMS, - "from_ms": session.FromMS, - "to_ms": session.ToMS, - "state": session.State, - "speed": session.Speed, - "progress": progress, - "params": session.Params, - "blobs": blobs, + "session_id": sessionID, + "current_ms": session.CurrentMS, + "from_ms": session.FromMS, + "to_ms": session.ToMS, + "state": session.State, + "speed": session.Speed, + "progress": progress, + "params": session.Params, + "blobs": blobs, "timestamp_ms": session.LastBlobTime, } diff --git a/mothership/internal/api/replay_test.go b/mothership/internal/api/replay_test.go index b52706e..1f9aeff 100644 --- a/mothership/internal/api/replay_test.go +++ b/mothership/internal/api/replay_test.go @@ -15,11 +15,11 @@ import ( // mockRecordingStore is a mock implementation of FrameReader for testing. type mockRecordingStore struct { - stats replay.Stats - scanFunc func(fn func(recvTimeNS int64, frame []byte) bool) error + stats replay.Stats + scanFunc func(fn func(recvTimeNS int64, frame []byte) bool) error scanRangeFunc func(fromNS, toNS int64, fn func(recvTimeNS int64, frame []byte) bool) error - closed bool - closeErr error + closed bool + closeErr error } func (m *mockRecordingStore) Stats() replay.Stats { @@ -669,10 +669,10 @@ func TestTune(t *testing.T) { return resp["session_id"].(string) }, body: tuneRequest{ - DeltaRMSThreshold: &deltaThreshold, - TauS: &tauS, - FresnelDecay: &fresnelDecay, - Subcarriers: &subcarriers, + DeltaRMSThreshold: &deltaThreshold, + TauS: &tauS, + FresnelDecay: &fresnelDecay, + Subcarriers: &subcarriers, BreathingSensitivity: &breathingSens, }, wantStatus: http.StatusOK, @@ -746,9 +746,9 @@ func TestTune(t *testing.T) { }, }, { - name: "malformed JSON", - setup: func(h *ReplayHandler) string { return "" }, - body: tuneRequest{}, + name: "malformed JSON", + setup: func(h *ReplayHandler) string { return "" }, + body: tuneRequest{}, wantStatus: http.StatusBadRequest, check: func(t *testing.T, resp map[string]interface{}) { if _, ok := resp["error"]; !ok { @@ -839,7 +839,7 @@ func TestReplaySessionLifecycle(t *testing.T) { // 2. Tune the session threshold := 0.03 tuneBody, _ := json.Marshal(tuneRequest{ - SessionID: sessionID, + SessionID: sessionID, DeltaRMSThreshold: &threshold, }) req = httptest.NewRequest("POST", "/api/replay/tune", bytes.NewReader(tuneBody)) diff --git a/mothership/internal/api/security.go b/mothership/internal/api/security.go index 7600bb8..3373dc4 100644 --- a/mothership/internal/api/security.go +++ b/mothership/internal/api/security.go @@ -55,24 +55,25 @@ type SecurityStatus struct { // SystemModeResponse represents the current system mode response. type SystemModeResponse struct { - Mode string `json:"mode"` // "home", "away", "sleep" - Armed bool `json:"armed"` - LearningUntil string `json:"learning_until,omitempty"` - AnomalyCount24h int `json:"anomaly_count_24h"` - ModelReady bool `json:"model_ready"` - LastChange string `json:"last_change,omitempty"` - LastChangeBy string `json:"last_change_by,omitempty"` + Mode string `json:"mode"` // "home", "away", "sleep" + Armed bool `json:"armed"` + LearningUntil string `json:"learning_until,omitempty"` + AnomalyCount24h int `json:"anomaly_count_24h"` + ModelReady bool `json:"model_ready"` + LastChange string `json:"last_change,omitempty"` + LastChangeBy string `json:"last_change_by,omitempty"` } // handleStatus returns the current security mode status. // Response JSON: -// { -// "armed": true, -// "mode": "armed", -// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready -// "anomaly_count_24h": 5, -// "model_ready": false -// } +// +// { +// "armed": true, +// "mode": "armed", +// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready +// "anomaly_count_24h": 5, +// "model_ready": false +// } func (h *SecurityHandler) handleStatus(w http.ResponseWriter, r *http.Request) { if h.detector == nil { http.Error(w, "detector not available", http.StatusServiceUnavailable) @@ -176,15 +177,16 @@ func (h *SecurityHandler) countAnomalies24h() int { // handleGetMode returns the current system mode (home/away/sleep). // Response JSON: -// { -// "mode": "home", -// "armed": false, -// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready -// "anomaly_count_24h": 5, -// "model_ready": false, -// "last_change": "2024-04-15T10:30:00Z", -// "last_change_by": "auto_away" -// } +// +// { +// "mode": "home", +// "armed": false, +// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready +// "anomaly_count_24h": 5, +// "model_ready": false, +// "last_change": "2024-04-15T10:30:00Z", +// "last_change_by": "auto_away" +// } func (h *SecurityHandler) handleGetMode(w http.ResponseWriter, r *http.Request) { if h.detector == nil { http.Error(w, "detector not available", http.StatusServiceUnavailable) @@ -216,10 +218,12 @@ func (h *SecurityHandler) handleGetMode(w http.ResponseWriter, r *http.Request) // handleSetMode sets the system mode (home/away/sleep). // Request body: -// { -// "mode": "away", // "home", "away", or "sleep" -// "reason": "manual" // optional reason for logging -// } +// +// { +// "mode": "away", // "home", "away", or "sleep" +// "reason": "manual" // optional reason for logging +// } +// // Response: SystemModeResponse func (h *SecurityHandler) handleSetMode(w http.ResponseWriter, r *http.Request) { if h.detector == nil { diff --git a/mothership/internal/api/security_test.go b/mothership/internal/api/security_test.go index 3c99c2e..289f580 100644 --- a/mothership/internal/api/security_test.go +++ b/mothership/internal/api/security_test.go @@ -16,14 +16,14 @@ import ( // mockDetectorProvider is a mock implementation of DetectorProvider for testing. type mockDetectorProvider struct { - mode analytics.SecurityMode - isActive bool - progress float64 - modelReady bool - activeAnomalies []*events.AnomalyEvent - history []*events.AnomalyEvent - modeChanges []analytics.SecurityMode - systemMode events.SystemMode + mode analytics.SecurityMode + isActive bool + progress float64 + modelReady bool + activeAnomalies []*events.AnomalyEvent + history []*events.AnomalyEvent + modeChanges []analytics.SecurityMode + systemMode events.SystemMode } func (m *mockDetectorProvider) GetSecurityMode() analytics.SecurityMode { @@ -131,11 +131,11 @@ func TestSecurityHandler_Status(t *testing.T) { } mock := &mockDetectorProvider{ - mode: tt.mode, - isActive: tt.isActive, - modelReady: tt.modelReady, - progress: tt.progress, - history: history, + mode: tt.mode, + isActive: tt.isActive, + modelReady: tt.modelReady, + progress: tt.progress, + history: history, } handler := NewSecurityHandler(mock) @@ -369,10 +369,10 @@ func TestSecurityHandler_NilDetector(t *testing.T) { func TestSecurityHandler_CountAnomalies24h(t *testing.T) { now := time.Now() history := []*events.AnomalyEvent{ - {Timestamp: now.Add(-1 * time.Hour)}, // Within 24h - {Timestamp: now.Add(-12 * time.Hour)}, // Within 24h - {Timestamp: now.Add(-25 * time.Hour)}, // Outside 24h - {Timestamp: now.Add(-48 * time.Hour)}, // Outside 24h + {Timestamp: now.Add(-1 * time.Hour)}, // Within 24h + {Timestamp: now.Add(-12 * time.Hour)}, // Within 24h + {Timestamp: now.Add(-25 * time.Hour)}, // Outside 24h + {Timestamp: now.Add(-48 * time.Hour)}, // Outside 24h } mock := &mockDetectorProvider{ diff --git a/mothership/internal/api/settings.go b/mothership/internal/api/settings.go index 378f4a3..bb52e1c 100644 --- a/mothership/internal/api/settings.go +++ b/mothership/internal/api/settings.go @@ -16,8 +16,8 @@ import ( // SettingsHandler manages application settings. // Settings are stored as key-value pairs in the settings table with JSON-encoded values. type SettingsHandler struct { - mu sync.RWMutex - db *sql.DB + mu sync.RWMutex + db *sql.DB // cache is an in-memory cache of settings for fast reads cache map[string]interface{} // editTracker tracks repeated edits for troubleshooting hints @@ -223,30 +223,30 @@ func (s *SettingsHandler) Delete(key string) error { // defaultSettings defines the default values for all known settings. // These are returned when a key hasn't been set in the database. var defaultSettings = map[string]interface{}{ - "fusion_rate_hz": 10.0, // Fusion loop rate in Hz - "grid_cell_m": 0.2, // Fresnel grid cell size in meters - "delta_rms_threshold": 0.02, // Motion detection threshold - "tau_s": 30.0, // EMA baseline time constant in seconds - "fresnel_decay": 2.0, // Fresnel zone weight decay rate - "n_subcarriers": 16, // Number of subcarriers for NBVI selection - "breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS) - "motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating - "dwell_seconds": 30, // Default dwell trigger duration in seconds - "vacant_seconds": 300, // Default vacant trigger duration in seconds - "max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously - "replay_retention_hours": 48, // CSI replay buffer retention in hours - "replay_max_mb": 360, // CSI replay buffer max size in MB - "security_mode": false, // Security mode enabled state - "security_mode_armed_at": nil, // Timestamp when security mode was armed - "events_archive_days": 90, // Events archive retention in days - "quiet_hours_start": "", // Quiet hours start time (HH:MM format) - "quiet_hours_end": "", // Quiet hours end time (HH:MM format) + "fusion_rate_hz": 10.0, // Fusion loop rate in Hz + "grid_cell_m": 0.2, // Fresnel grid cell size in meters + "delta_rms_threshold": 0.02, // Motion detection threshold + "tau_s": 30.0, // EMA baseline time constant in seconds + "fresnel_decay": 2.0, // Fresnel zone weight decay rate + "n_subcarriers": 16, // Number of subcarriers for NBVI selection + "breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS) + "motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating + "dwell_seconds": 30, // Default dwell trigger duration in seconds + "vacant_seconds": 300, // Default vacant trigger duration in seconds + "max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously + "replay_retention_hours": 48, // CSI replay buffer retention in hours + "replay_max_mb": 360, // CSI replay buffer max size in MB + "security_mode": false, // Security mode enabled state + "security_mode_armed_at": nil, // Timestamp when security mode was armed + "events_archive_days": 90, // Events archive retention in days + "quiet_hours_start": "", // Quiet hours start time (HH:MM format) + "quiet_hours_end": "", // Quiet hours end time (HH:MM format) // Auto-update settings - "auto_update_enabled": false, // Auto-update mode enabled - "quiet_window_start": "02:00", // Auto-update quiet window start (HH:MM) - "quiet_window_end": "05:00", // Auto-update quiet window end (HH:MM) - "canary_duration_min": 10, // Canary monitoring duration in minutes - "auto_update_quality_threshold": 0.05, // Quality degradation threshold (0-1) + "auto_update_enabled": false, // Auto-update mode enabled + "quiet_window_start": "02:00", // Auto-update quiet window start (HH:MM) + "quiet_window_end": "05:00", // Auto-update quiet window end (HH:MM) + "canary_duration_min": 10, // Canary monitoring duration in minutes + "auto_update_quality_threshold": 0.05, // Quality degradation threshold (0-1) } // RegisterRoutes registers settings endpoints on the given router. diff --git a/mothership/internal/api/settings_test.go b/mothership/internal/api/settings_test.go index d2d59a0..8a572c9 100644 --- a/mothership/internal/api/settings_test.go +++ b/mothership/internal/api/settings_test.go @@ -109,10 +109,10 @@ func TestSettingsHandler(t *testing.T) { method: "POST", path: "/api/settings", body: map[string]interface{}{ - "fusion_rate_hz": 12.0, - "delta_rms_threshold": 0.03, - "grid_cell_m": 0.15, - "max_tracked_blobs": 30, + "fusion_rate_hz": 12.0, + "delta_rms_threshold": 0.03, + "grid_cell_m": 0.15, + "max_tracked_blobs": 30, }, expectedStatus: http.StatusOK, checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { @@ -505,26 +505,26 @@ func TestSettingsDelete(t *testing.T) { // TestValidateSettings tests the settings validation. func TestValidateSettings(t *testing.T) { tests := []struct { - name string - settings map[string]interface{} - wantErr bool - errKey string + name string + settings map[string]interface{} + wantErr bool + errKey string }{ { name: "all valid settings", settings: map[string]interface{}{ - "fusion_rate_hz": 10.0, - "grid_cell_m": 0.2, - "delta_rms_threshold": 0.02, - "tau_s": 30.0, - "fresnel_decay": 2.0, - "n_subcarriers": 16, + "fusion_rate_hz": 10.0, + "grid_cell_m": 0.2, + "delta_rms_threshold": 0.02, + "tau_s": 30.0, + "fresnel_decay": 2.0, + "n_subcarriers": 16, "breathing_sensitivity": 0.005, - "motion_threshold": 0.05, - "dwell_seconds": 30, - "vacant_seconds": 300, - "max_tracked_blobs": 20, - "security_mode": true, + "motion_threshold": 0.05, + "dwell_seconds": 30, + "vacant_seconds": 300, + "max_tracked_blobs": 20, + "security_mode": true, }, wantErr: false, }, diff --git a/mothership/internal/api/status.go b/mothership/internal/api/status.go index 5092a3f..9553d2d 100644 --- a/mothership/internal/api/status.go +++ b/mothership/internal/api/status.go @@ -13,11 +13,11 @@ import ( // StatusHandler handles GET /api/status and GET /api/occupancy. type StatusHandler struct { - mu sync.RWMutex - pm ProcessorManagerProvider - zonesMgr ZonesManagerProvider - startTime time.Time - getNodeCount func() int + mu sync.RWMutex + pm ProcessorManagerProvider + zonesMgr ZonesManagerProvider + startTime time.Time + getNodeCount func() int } // ProcessorManagerProvider provides access to signal processor data. diff --git a/mothership/internal/api/triggers.go b/mothership/internal/api/triggers.go index c5ca52c..4867011 100644 --- a/mothership/internal/api/triggers.go +++ b/mothership/internal/api/triggers.go @@ -27,16 +27,16 @@ type TriggersHandler struct { // 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 + 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"` + 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. @@ -241,10 +241,10 @@ func (t *TriggersHandler) listTriggers(w http.ResponseWriter, r *http.Request) { } type createTriggerRequest struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled *bool `json:"enabled,omitempty"` - Condition string `json:"condition"` + 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"` @@ -564,8 +564,8 @@ func (t *TriggersHandler) EvaluateTriggers(blobs []BlobPos) []string { // Parse condition params var params struct { - DurationS *int `json:"duration_s"` - CountThreshold *int `json:"count_threshold"` + DurationS *int `json:"duration_s"` + CountThreshold *int `json:"count_threshold"` PersonID string `json:"person_id,omitempty"` VolumeID string `json:"volume_id,omitempty"` } diff --git a/mothership/internal/api/triggers_test.go b/mothership/internal/api/triggers_test.go index 79bebef..f1082f0 100644 --- a/mothership/internal/api/triggers_test.go +++ b/mothership/internal/api/triggers_test.go @@ -161,8 +161,8 @@ func TestCreateTrigger(t *testing.T) { wantID: "t1", }, { - name: "minimal valid trigger", - body: `{"id": "t2", "name": "Enter", "condition": "enter"}`, + name: "minimal valid trigger", + body: `{"id": "t2", "name": "Enter", "condition": "enter"}`, wantCode: http.StatusCreated, wantID: "t2", }, @@ -209,8 +209,8 @@ func TestCreateTrigger(t *testing.T) { wantID: "t6", }, { - name: "explicitly disabled", - body: `{"id": "t7", "name": "Off", "condition": "vacant", "enabled": false}`, + name: "explicitly disabled", + body: `{"id": "t7", "name": "Off", "condition": "vacant", "enabled": false}`, wantCode: http.StatusCreated, wantID: "t7", }, @@ -589,34 +589,34 @@ func TestTestTrigger(t *testing.T) { wantKey string }{ { - name: "test with no engine returns simulated", - setup: Trigger{ID: "t1", Name: "Sim", Condition: "dwell", Enabled: true}, - testID: "t1", - engine: nil, + name: "test with no engine returns simulated", + setup: Trigger{ID: "t1", Name: "Sim", Condition: "dwell", Enabled: true}, + testID: "t1", + engine: nil, wantCode: http.StatusOK, wantKey: "simulated", }, { - name: "test with engine that succeeds", - setup: Trigger{ID: "t1", Name: "Fire", Condition: "enter", Enabled: true}, - testID: "t1", - engine: &mockEngine{err: nil}, + name: "test with engine that succeeds", + setup: Trigger{ID: "t1", Name: "Fire", Condition: "enter", Enabled: true}, + testID: "t1", + engine: &mockEngine{err: nil}, wantCode: http.StatusOK, wantKey: "fired", }, { - name: "test with engine that fails", - setup: Trigger{ID: "t1", Name: "Fail", Condition: "leave", Enabled: true}, - testID: "t1", - engine: &mockEngine{err: fmt.Errorf("boom")}, + name: "test with engine that fails", + setup: Trigger{ID: "t1", Name: "Fail", Condition: "leave", Enabled: true}, + testID: "t1", + engine: &mockEngine{err: fmt.Errorf("boom")}, wantCode: http.StatusInternalServerError, wantKey: "test fire failed", }, { - name: "test nonexistent trigger", - setup: Trigger{ID: "t1", Name: "Exists", Condition: "enter", Enabled: true}, - testID: "nonexistent", - engine: nil, + name: "test nonexistent trigger", + setup: Trigger{ID: "t1", Name: "Exists", Condition: "enter", Enabled: true}, + testID: "nonexistent", + engine: nil, wantCode: http.StatusNotFound, wantKey: "trigger not found", }, diff --git a/mothership/internal/api/volume_triggers.go b/mothership/internal/api/volume_triggers.go index 18002c8..6cb4a67 100644 --- a/mothership/internal/api/volume_triggers.go +++ b/mothership/internal/api/volume_triggers.go @@ -389,13 +389,13 @@ func (h *VolumeTriggersHandler) createTrigger(w http.ResponseWriter, r *http.Req // Validate condition validConditions := map[string]bool{ - "enter": true, - "leave": true, - "dwell": true, - "vacant": true, + "enter": true, + "leave": true, + "dwell": true, + "vacant": true, "count": true, - "predicted_enter": true, - } + "predicted_enter": true, + } if !validConditions[req.Condition] { http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count, predicted_enter", http.StatusBadRequest) return diff --git a/mothership/internal/api/volume_triggers_test.go b/mothership/internal/api/volume_triggers_test.go index 88f9716..3143f2e 100644 --- a/mothership/internal/api/volume_triggers_test.go +++ b/mothership/internal/api/volume_triggers_test.go @@ -45,7 +45,7 @@ func validBoxShape() volume.ShapeJSON { return volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), } } @@ -237,7 +237,7 @@ func TestVolumeCreateTriggerAssignsID(t *testing.T) { func TestVolumeGetTrigger(t *testing.T) { tests := []struct { name string - setup bool // whether to seed a trigger + setup bool // whether to seed a trigger getID string // empty = use the seeded ID wantCode int wantErr string @@ -424,8 +424,8 @@ func TestVolumeUpdateTrigger(t *testing.T) { func TestVolumeDeleteTrigger(t *testing.T) { tests := []struct { name string - setup int // number of triggers to seed - deleteN int // 0 = delete nonexistent, 1 = delete first trigger + setup int // number of triggers to seed + deleteN int // 0 = delete nonexistent, 1 = delete first trigger wantCode int wantLen int }{ @@ -610,10 +610,10 @@ func TestTestTriggerEndpoint(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "dwell", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": "http://example.com/hook"}}, }, @@ -687,10 +687,10 @@ func TestTestTrigger_ReturnsErrorOnMissingURL(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{}}, }, @@ -743,10 +743,10 @@ func TestTestTrigger_4xxInTestDoesNotDisable(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}}, }, @@ -787,10 +787,10 @@ func TestEnableEndpoint(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: false, + Enabled: false, } id, err := handler.store.Create(trigger) @@ -836,10 +836,10 @@ func TestGetWebhookLogEndpoint(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := handler.store.Create(trigger) @@ -899,10 +899,10 @@ func TestWebhookPayloadSchema(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "dwell", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}}, }, @@ -959,10 +959,10 @@ func Test5xxDoesNotDisableTrigger(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}}, }, @@ -1012,10 +1012,10 @@ func Test4xxDisablesTrigger(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}}, }, @@ -1065,10 +1065,10 @@ func Test2xxResetsErrorCount(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}}, }, @@ -1129,10 +1129,10 @@ func TestTimeoutDoesNotDisable(t *testing.T) { Shape: volume.ShapeJSON{ Type: volume.ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []volume.Action{ {Type: "webhook", Params: map[string]interface{}{"url": "http://" + listener.Addr().String()}}, }, diff --git a/mothership/internal/api/zones.go b/mothership/internal/api/zones.go index e61ce2c..8897f30 100644 --- a/mothership/internal/api/zones.go +++ b/mothership/internal/api/zones.go @@ -25,10 +25,10 @@ type BlobIdentityProvider interface { // 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 - ident BlobIdentityProvider + mu sync.RWMutex + mgr *zones.Manager + bc dashboard.ZoneChangeBroadcaster + ident BlobIdentityProvider } // zoneWithOcc extends a zone with current occupancy and people list for API responses. diff --git a/mothership/internal/api/zones_test.go b/mothership/internal/api/zones_test.go index 5942edd..ad7a72a 100644 --- a/mothership/internal/api/zones_test.go +++ b/mothership/internal/api/zones_test.go @@ -214,11 +214,11 @@ func TestCreateZoneInvalid(t *testing.T) { body: ``, wantMsg: "invalid request body", }, - { - name: "missing name", - body: `{"id":"z1","x":0,"y":0,"z":0,"max_x":1,"max_y":1,"max_z":1}`, - wantMsg: "name is required", - }, + { + name: "missing name", + body: `{"id":"z1","x":0,"y":0,"z":0,"max_x":1,"max_y":1,"max_z":1}`, + wantMsg: "name is required", + }, } for _, tt := range tests { @@ -256,21 +256,21 @@ func TestUpdateZone(t *testing.T) { wantName string }{ { - name: "update zone name", - setup: zones.Zone{ID: "z1", Name: "Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5}, - update: zones.Zone{ID: "z1", Name: "Big Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 6, MaxY: 5, MaxZ: 3}, + name: "update zone name", + setup: zones.Zone{ID: "z1", Name: "Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5}, + update: zones.Zone{ID: "z1", Name: "Big Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 6, MaxY: 5, MaxZ: 3}, wantName: "Big Kitchen", }, { - name: "update zone type to bedroom", - setup: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5}, - update: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5, ZoneType: zones.ZoneTypeBedroom}, + name: "update zone type to bedroom", + setup: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5}, + update: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5, ZoneType: zones.ZoneTypeBedroom}, wantName: "Room", }, { - name: "update zone bounds", - setup: zones.Zone{ID: "z1", Name: "Box", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1}, - update: zones.Zone{ID: "z1", Name: "Box", MinX: 2, MinY: 3, MinZ: 1, MaxX: 10, MaxY: 8, MaxZ: 4}, + name: "update zone bounds", + setup: zones.Zone{ID: "z1", Name: "Box", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1}, + update: zones.Zone{ID: "z1", Name: "Box", MinX: 2, MinY: 3, MinZ: 1, MaxX: 10, MaxY: 8, MaxZ: 4}, wantName: "Box", }, } diff --git a/mothership/internal/auth/handler.go b/mothership/internal/auth/handler.go index 3af3f78..118d808 100644 --- a/mothership/internal/auth/handler.go +++ b/mothership/internal/auth/handler.go @@ -21,8 +21,8 @@ import ( // Handler handles authentication endpoints. type Handler struct { - db *sql.DB - secretKey []byte // for session token signing + db *sql.DB + secretKey []byte // for session token signing mothershipID string // cached mothership ID } diff --git a/mothership/internal/auth/handler_test.go b/mothership/internal/auth/handler_test.go index 71406e2..6c8ce9f 100644 --- a/mothership/internal/auth/handler_test.go +++ b/mothership/internal/auth/handler_test.go @@ -581,10 +581,10 @@ func TestInstallSecret_NodeTokenDerivation(t *testing.T) { defer h.Close() //nolint:errcheck tests := []struct { - name string - mac string - mac2 string - same bool // whether mac and mac2 should produce the same token + name string + mac string + mac2 string + same bool // whether mac and mac2 should produce the same token }{ {"same MAC always produces same token", "AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF", true}, {"different MACs produce different tokens", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", false}, diff --git a/mothership/internal/automation/engine_test.go b/mothership/internal/automation/engine_test.go index 37a4bd0..b9d3282 100644 --- a/mothership/internal/automation/engine_test.go +++ b/mothership/internal/automation/engine_test.go @@ -265,11 +265,11 @@ func TestTimeWindowCondition(t *testing.T) { minute int expected bool }{ - {8, 0, true}, // 08:00 - within 07:00-18:00 - {12, 0, true}, // 12:00 - within 07:00-18:00 - {17, 59, true}, // 17:59 - within 07:00-18:00 - {6, 59, false}, // 06:59 - outside 07:00-18:00 - {18, 1, false}, // 18:01 - outside 07:00-18:00 + {8, 0, true}, // 08:00 - within 07:00-18:00 + {12, 0, true}, // 12:00 - within 07:00-18:00 + {17, 59, true}, // 17:59 - within 07:00-18:00 + {6, 59, false}, // 06:59 - outside 07:00-18:00 + {18, 1, false}, // 18:01 - outside 07:00-18:00 } for _, tc := range dayTestCases { @@ -448,9 +448,9 @@ func TestWebhookDispatch(t *testing.T) { }) engine.ProcessEvent(Event{ - Type: TriggerZoneEnter, - ZoneID: "test", - ZoneName: "Test Zone", + Type: TriggerZoneEnter, + ZoneID: "test", + ZoneName: "Test Zone", PersonName: "Alice", Timestamp: time.Now(), }) @@ -503,8 +503,8 @@ func TestWebhookRetry(t *testing.T) { } engine.ProcessEvent(Event{ - Type: TriggerZoneEnter, - ZoneID: "test", + Type: TriggerZoneEnter, + ZoneID: "test", Timestamp: time.Now(), }) @@ -544,8 +544,8 @@ func TestMQTTPublish(t *testing.T) { } engine.ProcessEvent(Event{ - Type: TriggerZoneEnter, - ZoneID: "test", + Type: TriggerZoneEnter, + ZoneID: "test", Timestamp: time.Now(), }) @@ -639,8 +639,8 @@ func TestFireCountIncrement(t *testing.T) { // Trigger 3 times for i := 0; i < 3; i++ { engine.ProcessEvent(Event{ - Type: TriggerZoneEnter, - ZoneID: "test", + Type: TriggerZoneEnter, + ZoneID: "test", Timestamp: time.Now(), }) time.Sleep(10 * time.Millisecond) @@ -663,22 +663,22 @@ func TestTriggerVolumeContainment(t *testing.T) { // Box volume boxVolume := TriggerVolume{ - ID: "test-box", - Type: "box", - MinX: 1.0, MinY: 0.0, MinZ: 1.0, - MaxX: 3.0, MaxY: 2.0, MaxZ: 3.0, + ID: "test-box", + Type: "box", + MinX: 1.0, MinY: 0.0, MinZ: 1.0, + MaxX: 3.0, MaxY: 2.0, MaxZ: 3.0, } testCases := []struct { x, y, z float64 expected bool }{ - {2.0, 1.0, 2.0, true}, // Center - {1.0, 0.0, 1.0, true}, // Corner - {3.0, 2.0, 3.0, true}, // Opposite corner - {0.0, 1.0, 2.0, false}, // Outside X - {2.0, 3.0, 2.0, false}, // Outside Y - {2.0, 1.0, 4.0, false}, // Outside Z + {2.0, 1.0, 2.0, true}, // Center + {1.0, 0.0, 1.0, true}, // Corner + {3.0, 2.0, 3.0, true}, // Opposite corner + {0.0, 1.0, 2.0, false}, // Outside X + {2.0, 3.0, 2.0, false}, // Outside Y + {2.0, 1.0, 4.0, false}, // Outside Z } for _, tc := range testCases { @@ -691,20 +691,20 @@ func TestTriggerVolumeContainment(t *testing.T) { // Sphere volume sphereVolume := TriggerVolume{ - ID: "test-sphere", - Type: "sphere", - CenterX: 2.0, CenterY: 2.0, CenterZ: 2.0, - Radius: 1.0, + ID: "test-sphere", + Type: "sphere", + CenterX: 2.0, CenterY: 2.0, CenterZ: 2.0, + Radius: 1.0, } sphereCases := []struct { x, y, z float64 expected bool }{ - {2.0, 2.0, 2.0, true}, // Center - {2.0, 2.0, 3.0, true}, // On surface - {2.0, 2.0, 3.1, false}, // Just outside - {1.0, 1.0, 1.0, false}, // Corner (distance > 1) + {2.0, 2.0, 2.0, true}, // Center + {2.0, 2.0, 3.0, true}, // On surface + {2.0, 2.0, 3.1, false}, // Just outside + {1.0, 1.0, 1.0, false}, // Corner (distance > 1) } for _, tc := range sphereCases { @@ -717,9 +717,9 @@ func TestTriggerVolumeContainment(t *testing.T) { // Cylinder volume cylinderVolume := TriggerVolume{ - ID: "test-cylinder", - Type: "cylinder", - BaseX: 2.0, BaseZ: 2.0, + ID: "test-cylinder", + Type: "cylinder", + BaseX: 2.0, BaseZ: 2.0, BaseRadius: 1.0, MinHeight: 0.0, MaxHeight: 2.0, } @@ -728,11 +728,11 @@ func TestTriggerVolumeContainment(t *testing.T) { x, y, z float64 expected bool }{ - {2.0, 1.0, 2.0, true}, // Center - {2.0, 2.5, 2.0, false}, // Above height - {3.0, 1.0, 2.0, true}, // On edge - {3.5, 1.0, 2.0, false}, // Outside radius - {2.0, 1.0, 3.0, true}, // On edge (dist=1.0 from center in Z) + {2.0, 1.0, 2.0, true}, // Center + {2.0, 2.5, 2.0, false}, // Above height + {3.0, 1.0, 2.0, true}, // On edge + {3.5, 1.0, 2.0, false}, // Outside radius + {2.0, 1.0, 3.0, true}, // On edge (dist=1.0 from center in Z) } for _, tc := range cylinderCases { @@ -854,7 +854,7 @@ func TestTriggerVolumeCRUD(t *testing.T) { Type: "box", Enabled: true, MinX: 0, MinY: 0, MinZ: 0, - MaxX: 1, MaxY: 1, MaxZ: 1, + MaxX: 1, MaxY: 1, MaxZ: 1, } if err := engine.CreateTriggerVolume(v); err != nil { t.Fatal(err) @@ -1006,8 +1006,8 @@ func TestZoneOccupancyCondition(t *testing.T) { triggered = true }) engine.ProcessEvent(Event{ - Type: TriggerZoneEnter, - ZoneID: "kitchen", + Type: TriggerZoneEnter, + ZoneID: "kitchen", Timestamp: time.Now(), }) diff --git a/mothership/internal/autoupdate/adapters.go b/mothership/internal/autoupdate/adapters.go index bdf4e0a..6bfd504 100644 --- a/mothership/internal/autoupdate/adapters.go +++ b/mothership/internal/autoupdate/adapters.go @@ -52,8 +52,8 @@ type NodeConnectedGetter interface { // nodeProviderAdapter adapts fleet.Registry and fleet.Manager to implement ota.NodeProvider. type nodeProviderAdapter struct { - registry *fleet.Registry - weather *fleet.LinkWeatherDiagnostics + registry *fleet.Registry + weather *fleet.LinkWeatherDiagnostics connGetter NodeConnectedGetter } @@ -68,8 +68,8 @@ func NewNodeProvider(registry *fleet.Registry, weather *fleet.LinkWeatherDiagnos // NewNodeProviderWithConnected creates an ota.NodeProvider with a connected nodes getter. func NewNodeProviderWithConnected(registry *fleet.Registry, weather *fleet.LinkWeatherDiagnostics, connGetter NodeConnectedGetter) ota.NodeProvider { return &nodeProviderAdapter{ - registry: registry, - weather: weather, + registry: registry, + weather: weather, connGetter: connGetter, } } diff --git a/mothership/internal/ble/handler.go b/mothership/internal/ble/handler.go index ce9e0a1..71ee805 100644 --- a/mothership/internal/ble/handler.go +++ b/mothership/internal/ble/handler.go @@ -295,7 +295,7 @@ func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Privacy-Notice", "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.") writeJSON(w, map[string]interface{}{ - "devices": devices, + "devices": devices, "privacy_notice": "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.", }) } @@ -531,7 +531,7 @@ func (h *Handler) mergeDevices(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, map[string]interface{}{ - "merged": device, + "merged": device, "message": "Devices merged successfully. " + req.MAC2 + " has been removed.", }) } @@ -737,8 +737,8 @@ func (h *Handler) getDeviceAliases(w http.ResponseWriter, r *http.Request) { type splitDeviceRequest struct { CanonicalAddr string `json:"canonical_addr"` // The canonical device address - AliasAddr string `json:"alias_addr"` // The alias to split off - NewPersonID string `json:"new_person_id"` // Optional: assign to different person + AliasAddr string `json:"alias_addr"` // The alias to split off + NewPersonID string `json:"new_person_id"` // Optional: assign to different person } // splitDevice splits an alias from its canonical device, creating a separate device entry. diff --git a/mothership/internal/ble/identity.go b/mothership/internal/ble/identity.go index e76d944..aea3d78 100644 --- a/mothership/internal/ble/identity.go +++ b/mothership/internal/ble/identity.go @@ -10,16 +10,16 @@ import ( // Triangulation parameters per specification const ( - RefDistance = 1.0 // d0 = 1.0m reference distance - RefRSSI = -65 // RSSI at 1m in typical indoor environment (dBm) - PathLossExp = 2.5 // Indoor path loss exponent (typical range 2.0-3.5) - RSSINoiseSigma = 5.0 // RSSI noise sigma in dBm + RefDistance = 1.0 // d0 = 1.0m reference distance + RefRSSI = -65 // RSSI at 1m in typical indoor environment (dBm) + PathLossExp = 2.5 // Indoor path loss exponent (typical range 2.0-3.5) + RSSINoiseSigma = 5.0 // RSSI noise sigma in dBm ) // Matching thresholds per specification const ( - MaxBLEBlobDistance = 2.0 // Maximum distance for BLE-to-blob matching (metres) - MinMatchConfidence = 0.6 // Minimum confidence for identity assignment + MaxBLEBlobDistance = 2.0 // Maximum distance for BLE-to-blob matching (metres) + MinMatchConfidence = 0.6 // Minimum confidence for identity assignment IdentityPersistence = 5 * time.Minute ObservationWindow = 5 * time.Second ObservationWindowLong = 15 * time.Second @@ -62,15 +62,15 @@ type TriangulatedDevice struct { // IdentityMatcher matches BLE devices to CSI blobs using RSSI triangulation. type IdentityMatcher struct { - registry *Registry - rssiCache *RSSICache - nodePos NodePositionAccessor + registry *Registry + rssiCache *RSSICache + nodePos NodePositionAccessor rotationDetector *RotationDetector // For address rotation detection mu sync.RWMutex - matches map[int]*IdentityMatch // blobID -> match + matches map[int]*IdentityMatch // blobID -> match bleOnlyTracks map[string]*IdentityMatch // personID -> BLE-only placeholder - persistentIdent map[int]*IdentityMatch // blobID -> persisted identity (for 5-min persistence) + persistentIdent map[int]*IdentityMatch // blobID -> persisted identity (for 5-min persistence) lastBLEUpdate time.Time cachedDevices []*TriangulatedDevice @@ -102,9 +102,9 @@ func NewIdentityMatcher(registry *Registry, rssiCache *RSSICache, nodePos NodePo // UpdateBlobs processes new blob positions and matches them to BLE devices. // This implements the full BLE-to-blob matching algorithm per specification. func (m *IdentityMatcher) UpdateBlobs(blobs []struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 }) { m.mu.Lock() defer m.mu.Unlock() @@ -171,7 +171,7 @@ func (m *IdentityMatcher) triangulateAllDevices(now time.Time) []*TriangulatedDe if processed[dev.Addr] { // Update alias last_seen timestamp if this is an alias if addr != dev.Addr { - _ = m.registry.UpdateAliasLastSeen(addr); //nolint:errcheck // best-effort + _ = m.registry.UpdateAliasLastSeen(addr) //nolint:errcheck // best-effort } continue } @@ -188,7 +188,7 @@ func (m *IdentityMatcher) triangulateAllDevices(now time.Time) []*TriangulatedDe // Update alias last_seen timestamp if addr != dev.Addr { - _ = m.registry.UpdateAliasLastSeen(addr); //nolint:errcheck // best-effort + _ = m.registry.UpdateAliasLastSeen(addr) //nolint:errcheck // best-effort } // Find most recent observation age @@ -230,8 +230,8 @@ func (m *IdentityMatcher) triangulate(readings []*RSSIObservation) (Position, fl // Convert RSSI readings to distance estimates with node positions type nodeReading struct { - x, y, z float64 - rssi int + x, y, z float64 + rssi int distance float64 weight float64 } @@ -365,9 +365,9 @@ func rssiToDistance(rssi int) float64 { // assignBLEToBlobs assigns triangulated BLE devices to the nearest CSI blobs. func (m *IdentityMatcher) assignBLEToBlobs(devices []*TriangulatedDevice, blobs []struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 }, now time.Time) { // Track which blobs have been assigned assignedBlobs := make(map[int]bool) @@ -387,9 +387,9 @@ func (m *IdentityMatcher) assignBLEToBlobs(devices []*TriangulatedDevice, blobs // Find nearest blob within 2m (using horizontal plane only) var bestBlob *struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 } bestDist := MaxBLEBlobDistance @@ -506,9 +506,9 @@ func computeMatchConfidence(td *TriangulatedDevice, blobDist float64) float64 { // createBLEOnlyTracks creates placeholder tracks for BLE devices without nearby CSI blobs. func (m *IdentityMatcher) createBLEOnlyTracks(devices []*TriangulatedDevice, blobs []struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 }, now time.Time) { // Track which persons already have blob assignments hasBlobAssignment := make(map[string]bool) diff --git a/mothership/internal/ble/identity_test.go b/mothership/internal/ble/identity_test.go index 02d805f..6152112 100644 --- a/mothership/internal/ble/identity_test.go +++ b/mothership/internal/ble/identity_test.go @@ -24,9 +24,9 @@ func (m *mockNodePositionAccessor) GetNodePosition(mac string) (x, y, z float64, func TestRSSIToDistance(t *testing.T) { tests := []struct { - name string - rssi int - expected float64 + name string + rssi int + expected float64 tolerance float64 }{ { @@ -71,9 +71,9 @@ func TestTriangulationWithThreeNodes(t *testing.T) { // Setup mock node positions (equilateral triangle at known positions) mockNodes := &mockNodePositionAccessor{ positions: map[string][3]float64{ - "node:00:01": {0.0, 1.5, 0.0}, // Origin - "node:00:02": {4.0, 1.5, 0.0}, // 4m along X - "node:00:03": {2.0, 1.5, 3.46}, // ~3.46m along Z (equilateral) + "node:00:01": {0.0, 1.5, 0.0}, // Origin + "node:00:02": {4.0, 1.5, 0.0}, // 4m along X + "node:00:03": {2.0, 1.5, 3.46}, // ~3.46m along Z (equilateral) }, } @@ -271,9 +271,9 @@ func TestConfidenceGate(t *testing.T) { cache.AddWithTime("aa:bb:cc:dd:ee:01", "node:00:01", -65, now) blobs := []struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 }{ {ID: 1, X: 1.5, Y: 1.5, Z: 0.0, Weight: 0.9}, // 1.5m from node } @@ -373,9 +373,9 @@ func TestBLEOnlyPlaceholderTrack(t *testing.T) { // Blob is far away (> 2m from triangulated position) blobs := []struct { - ID int + ID int X, Y, Z float64 - Weight float64 + Weight float64 }{ {ID: 1, X: 10.0, Y: 1.5, Z: 10.0, Weight: 0.9}, // Far from BLE position } @@ -545,11 +545,11 @@ func TestIdentityHandoffOnMACRotation(t *testing.T) { func TestComputeMatchConfidence(t *testing.T) { tests := []struct { - name string - td *TriangulatedDevice - blobDist float64 - minConf float64 - maxConf float64 + name string + td *TriangulatedDevice + blobDist float64 + minConf float64 + maxConf float64 }{ { name: "High confidence: recent observation, 3+ nodes, low residual, close blob", diff --git a/mothership/internal/ble/registry.go b/mothership/internal/ble/registry.go index c521211..4b962fc 100644 --- a/mothership/internal/ble/registry.go +++ b/mothership/internal/ble/registry.go @@ -37,7 +37,7 @@ var ManufacturerInfo = map[int]struct { Name string Type DeviceType }{ - 0x004C: {"Apple", DeviceTypeApplePhone}, // Apple - iPhone, iPad, AirPods, Apple Watch + 0x004C: {"Apple", DeviceTypeApplePhone}, // Apple - iPhone, iPad, AirPods, Apple Watch 0x0006: {"Microsoft", DeviceTypeMicrosoft}, // Microsoft - Windows devices 0x0075: {"Samsung", DeviceTypeSamsung}, // Samsung - phones/tablets 0x009E: {"Fitbit", DeviceTypeFitbit}, // Fitbit - fitness trackers @@ -49,20 +49,20 @@ var ManufacturerInfo = map[int]struct { // DeviceRecord represents a registered BLE device. type DeviceRecord struct { - Addr string `json:"mac"` // MAC address - Name string `json:"name"` // User-assigned name (e.g., "Alice's Phone") - Label string `json:"label"` // Short label for display - Manufacturer string `json:"manufacturer"` // Auto-detected manufacturer name - DeviceType DeviceType `json:"device_type"` // Auto-detected or manual type - DeviceName string `json:"device_name"` // Name from advertising (e.g., "iPhone") - MfrID int `json:"mfr_id"` // Manufacturer ID from advertising - MfrDataHex string `json:"mfr_data_hex"` // Raw manufacturer data (hex) - PersonID string `json:"person_id"` // FK to people.id - PersonName string `json:"person_name"` // Person name (joined from people) - PersonColor string `json:"person_color"` // Person's color (joined from people) - RSSIMin int `json:"rssi_min"` // Min RSSI observed - RSSIMax int `json:"rssi_max"` // Max RSSI observed - RSSIAvg int `json:"rssi_avg"` // Average RSSI + Addr string `json:"mac"` // MAC address + Name string `json:"name"` // User-assigned name (e.g., "Alice's Phone") + Label string `json:"label"` // Short label for display + Manufacturer string `json:"manufacturer"` // Auto-detected manufacturer name + DeviceType DeviceType `json:"device_type"` // Auto-detected or manual type + DeviceName string `json:"device_name"` // Name from advertising (e.g., "iPhone") + MfrID int `json:"mfr_id"` // Manufacturer ID from advertising + MfrDataHex string `json:"mfr_data_hex"` // Raw manufacturer data (hex) + PersonID string `json:"person_id"` // FK to people.id + PersonName string `json:"person_name"` // Person name (joined from people) + PersonColor string `json:"person_color"` // Person's color (joined from people) + RSSIMin int `json:"rssi_min"` // Min RSSI observed + RSSIMax int `json:"rssi_max"` // Max RSSI observed + RSSIAvg int `json:"rssi_avg"` // Average RSSI FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` LastSeenNode string `json:"last_seen_node"` // MAC of node that last saw this device @@ -74,9 +74,9 @@ type DeviceRecord struct { // Person represents a named person in the system. type Person struct { - ID string `json:"id"` // UUID - Name string `json:"name"` // Display name - Color string `json:"color"` // Hex color for dashboard + ID string `json:"id"` // UUID + Name string `json:"name"` // Display name + Color string `json:"color"` // Hex color for dashboard CreatedAt time.Time `json:"created_at"` } @@ -702,8 +702,8 @@ func (r *Registry) GetDeviceSightingHistory(mac string, limit int) ([]SightingHi // SightingHistoryEntry represents a single sighting event in the device history. type SightingHistoryEntry struct { Timestamp time.Time `json:"timestamp"` - RSSIdBm int `json:"rssi_dbm"` - NodeMAC string `json:"node_mac"` + RSSIdBm int `json:"rssi_dbm"` + NodeMAC string `json:"node_mac"` } // UpdateLabel updates the user-assigned label for a device. @@ -1238,10 +1238,10 @@ func generateUUID() string { // BLEDeviceAlias represents an address alias for a device. type BLEDeviceAlias struct { - Addr string `json:"addr"` // The rotated address - CanonicalAddr string `json:"canonical_addr"` // The primary/canonical address - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen"` + Addr string `json:"addr"` // The rotated address + CanonicalAddr string `json:"canonical_addr"` // The primary/canonical address + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` } // AddAlias adds a new address alias for a canonical device address. @@ -1438,20 +1438,20 @@ func (r *Registry) GetCurrentDevices() []map[string]interface{} { "last_seen": d.LastSeenAt.UnixMilli(), "blob_id": nil, "manufacturer": d.Manufacturer, - "device_type": d.DeviceType, - "device_name": d.DeviceName, - "mfr_id": d.MfrID, - "person_id": d.PersonID, - "person_name": d.PersonName, - "rssi_min": d.RSSIMin, - "rssi_max": d.RSSIMax, - "rssi_avg": d.RSSIAvg, - "rssi_count": r.GetDeviceRSSICount(d.Addr), - "first_seen_at": d.FirstSeenAt.UnixMilli(), - "last_seen_at": d.LastSeenAt.UnixMilli(), + "device_type": d.DeviceType, + "device_name": d.DeviceName, + "mfr_id": d.MfrID, + "person_id": d.PersonID, + "person_name": d.PersonName, + "rssi_min": d.RSSIMin, + "rssi_max": d.RSSIMax, + "rssi_avg": d.RSSIAvg, + "rssi_count": r.GetDeviceRSSICount(d.Addr), + "first_seen_at": d.FirstSeenAt.UnixMilli(), + "last_seen_at": d.LastSeenAt.UnixMilli(), "last_seen_node": d.LastSeenNode, - "is_wearable": d.IsWearable, - "enabled": d.Enabled, + "is_wearable": d.IsWearable, + "enabled": d.Enabled, } } return result diff --git a/mothership/internal/ble/rotation.go b/mothership/internal/ble/rotation.go index 0e1a109..f6c37c3 100644 --- a/mothership/internal/ble/rotation.go +++ b/mothership/internal/ble/rotation.go @@ -13,17 +13,17 @@ import ( // Rotation constants per specification const ( // Rotation detection windows - RotationTimeWindow = 90 * time.Second // Time window to look for rotation patterns - RotationRSSIThreshold = 10 // RSSI similarity threshold (dBm) - RotationMinScore = 0.7 // Minimum score to consider a rotation match - RotationConfirmCount = 3 // Consecutive confirmations needed to merge - RotationGracePeriod = 5 * time.Minute // Grace period before clearing identity - RotationStaleThreshold = 5 * time.Minute // Time after which an alias is considered stale + RotationTimeWindow = 90 * time.Second // Time window to look for rotation patterns + RotationRSSIThreshold = 10 // RSSI similarity threshold (dBm) + RotationMinScore = 0.7 // Minimum score to consider a rotation match + RotationConfirmCount = 3 // Consecutive confirmations needed to merge + RotationGracePeriod = 5 * time.Minute // Grace period before clearing identity + RotationStaleThreshold = 5 * time.Minute // Time after which an alias is considered stale // Scoring weights for rotation detection - WeightManufacturerMatch = 0.50 // Manufacturer data fingerprint - WeightRSSIProximity = 0.35 // Time + RSSI proximity - WeightTimeGap = 0.15 // Time gap factor + WeightManufacturerMatch = 0.50 // Manufacturer data fingerprint + WeightRSSIProximity = 0.35 // Time + RSSI proximity + WeightTimeGap = 0.15 // Time gap factor ) // RotationCandidate represents a possible address rotation match. @@ -39,25 +39,25 @@ type RotationCandidate struct { // RotationDetector implements BLE address rotation detection heuristics. type RotationDetector struct { - registry *Registry + registry *Registry rssiCache *RSSICache - mu sync.RWMutex - candidates map[string]*RotationCandidate // canonical_addr -> candidate - rotationHistory map[string][]string // canonical_addr -> list of rotated addresses - lastCheck time.Time - gracePeriodExpiries map[string]time.Time // canonical_addr -> identity expiry + mu sync.RWMutex + candidates map[string]*RotationCandidate // canonical_addr -> candidate + rotationHistory map[string][]string // canonical_addr -> list of rotated addresses + lastCheck time.Time + gracePeriodExpiries map[string]time.Time // canonical_addr -> identity expiry } // NewRotationDetector creates a new rotation detector. func NewRotationDetector(registry *Registry, rssiCache *RSSICache) *RotationDetector { return &RotationDetector{ - registry: registry, - rssiCache: rssiCache, - candidates: make(map[string]*RotationCandidate), - rotationHistory: make(map[string][]string), - gracePeriodExpiries: make(map[string]time.Time), - lastCheck: time.Now(), + registry: registry, + rssiCache: rssiCache, + candidates: make(map[string]*RotationCandidate), + rotationHistory: make(map[string][]string), + gracePeriodExpiries: make(map[string]time.Time), + lastCheck: time.Now(), } } @@ -274,7 +274,7 @@ func (r *RotationDetector) compareRSSIProximity(oldReadings, newReadings []*RSSI timeScore = 0.1 } else { // Linear decay from 1.0 to 0.5 over the window - timeScore = 1.0 - (0.5*float64(timeGap)/float64(RotationTimeWindow)) + timeScore = 1.0 - (0.5 * float64(timeGap) / float64(RotationTimeWindow)) } // Check for same-node observations (strongest signal) diff --git a/mothership/internal/ble/rotation_test.go b/mothership/internal/ble/rotation_test.go index 4ff3787..87695c1 100644 --- a/mothership/internal/ble/rotation_test.go +++ b/mothership/internal/ble/rotation_test.go @@ -114,11 +114,11 @@ func TestCalculateTimeGapScore(t *testing.T) { now := time.Now() tests := []struct { - name string + name string oldReadings []*RSSIObservation newReadings []*RSSIObservation - minScore float64 - maxScore float64 + minScore float64 + maxScore float64 }{ { name: "immediate appearance (ideal)", diff --git a/mothership/internal/briefing/briefing.go b/mothership/internal/briefing/briefing.go index 07e1a38..cb112f5 100644 --- a/mothership/internal/briefing/briefing.go +++ b/mothership/internal/briefing/briefing.go @@ -81,7 +81,7 @@ func NewGenerator(dbPath string) (*Generator, error) { // Unwrap if it's JSON if strings.HasPrefix(weatherURL, `"`) { var url string - _ = json.Unmarshal([]byte(weatherURL), &url); //nolint:errcheck + _ = json.Unmarshal([]byte(weatherURL), &url) //nolint:errcheck weatherURL = url } } diff --git a/mothership/internal/dashboard/hub.go b/mothership/internal/dashboard/hub.go index ba355ec..d12a45f 100644 --- a/mothership/internal/dashboard/hub.go +++ b/mothership/internal/dashboard/hub.go @@ -26,13 +26,13 @@ type Hub struct { ingestionState IngestionState // Additional state providers - bleState BLEState - triggerState TriggerState - systemHealth SystemHealthProvider - zoneState ZoneStateProvider - eventStore EventStore - securityState SecurityStateProvider - sleepState SleepStateProvider + bleState BLEState + triggerState TriggerState + systemHealth SystemHealthProvider + zoneState ZoneStateProvider + eventStore EventStore + securityState SecurityStateProvider + sleepState SleepStateProvider briefingProvider BriefingProvider // Pending events buffer — events accumulated between 10 Hz delta ticks. @@ -57,17 +57,17 @@ type Hub struct { // snapshotCache holds serialised JSON bytes for each snapshot field, // allowing cheap byte-level comparison when computing deltas. type snapshotCache struct { - blobsJSON []byte - nodesJSON []byte - zonesJSON []byte - portalsJSON []byte - linksJSON []byte - bleJSON []byte - triggersJSON []byte - motionStatesJSON []byte - securityJSON []byte - confidence int - timestampMs int64 + blobsJSON []byte + nodesJSON []byte + zonesJSON []byte + portalsJSON []byte + linksJSON []byte + bleJSON []byte + triggersJSON []byte + motionStatesJSON []byte + securityJSON []byte + confidence int + timestampMs int64 } // ZoneStateProvider is an interface to query zone data for the dashboard snapshot. @@ -80,46 +80,46 @@ type ZoneStateProvider interface { // PortalSnapshot is the wire format for a portal in the dashboard snapshot. type PortalSnapshot struct { - ID string `json:"id"` - Name string `json:"name"` - ZoneA string `json:"zone_a"` - ZoneB string `json:"zone_b"` - P1X float64 `json:"p1_x"` - P1Y float64 `json:"p1_y"` - P1Z float64 `json:"p1_z"` - P2X float64 `json:"p2_x"` - P2Y float64 `json:"p2_y"` - P2Z float64 `json:"p2_z"` - P3X float64 `json:"p3_x"` - P3Y float64 `json:"p3_y"` - P3Z float64 `json:"p3_z"` - NX float64 `json:"n_x"` - NY float64 `json:"n_y"` - NZ float64 `json:"n_z"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Enabled bool `json:"enabled"` + ID string `json:"id"` + Name string `json:"name"` + ZoneA string `json:"zone_a"` + ZoneB string `json:"zone_b"` + P1X float64 `json:"p1_x"` + P1Y float64 `json:"p1_y"` + P1Z float64 `json:"p1_z"` + P2X float64 `json:"p2_x"` + P2Y float64 `json:"p2_y"` + P2Z float64 `json:"p2_z"` + P3X float64 `json:"p3_x"` + P3Y float64 `json:"p3_y"` + P3Z float64 `json:"p3_z"` + NX float64 `json:"n_x"` + NY float64 `json:"n_y"` + NZ float64 `json:"n_z"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Enabled bool `json:"enabled"` } // ZoneSnapshot is the wire format for a zone in the dashboard snapshot. type ZoneSnapshot struct { - ID string `json:"id"` - Name string `json:"name"` - Count int `json:"count"` - People []string `json:"people"` - MinX float64 `json:"x"` - MinY float64 `json:"y"` - MinZ float64 `json:"z"` - SizeX float64 `json:"w"` - SizeY float64 `json:"d"` - SizeZ float64 `json:"h"` - OccStatus string `json:"occ_status,omitempty"` // "uncertain" or "reconciled" + ID string `json:"id"` + Name string `json:"name"` + Count int `json:"count"` + People []string `json:"people"` + MinX float64 `json:"x"` + MinY float64 `json:"y"` + MinZ float64 `json:"z"` + SizeX float64 `json:"w"` + SizeY float64 `json:"d"` + SizeZ float64 `json:"h"` + OccStatus string `json:"occ_status,omitempty"` // "uncertain" or "reconciled" } // ZoneOccupancySnapshot provides occupancy counts for zones. type ZoneOccupancySnapshot struct { - Count int `json:"count"` - BlobIDs []int `json:"blob_ids"` + Count int `json:"count"` + BlobIDs []int `json:"blob_ids"` } // IngestionState is an interface to query node/link/motion state from ingestion @@ -915,16 +915,16 @@ func (h *Hub) broadcastBLEScan() { // This implements the fleet.FleetChangeBroadcaster interface. func (h *Hub) BroadcastFleetChange(event fleet.FleetChangeEvent) { msg := map[string]interface{}{ - "type": "fleet_change", - "timestamp": event.Timestamp.UnixMilli(), - "trigger_reason": event.TriggerReason, - "mean_gdop_before": event.MeanGDOPBefore, - "mean_gdop_after": event.MeanGDOPAfter, - "coverage_before": event.CoverageBefore, - "coverage_after": event.CoverageAfter, - "coverage_delta": event.CoverageDelta, - "is_degradation": event.IsDegradation, - "role_assignments": event.RoleAssignments, + "type": "fleet_change", + "timestamp": event.Timestamp.UnixMilli(), + "trigger_reason": event.TriggerReason, + "mean_gdop_before": event.MeanGDOPBefore, + "mean_gdop_after": event.MeanGDOPAfter, + "coverage_before": event.CoverageBefore, + "coverage_after": event.CoverageAfter, + "coverage_delta": event.CoverageDelta, + "is_degradation": event.IsDegradation, + "role_assignments": event.RoleAssignments, } if event.OfflineMAC != "" { @@ -993,12 +993,12 @@ func (h *Hub) BroadcastFleetHealth(nodes []fleet.NodeRecord, roles map[string]st // BroadcastFleetHistory broadcasts optimisation history to dashboard. func (h *Hub) BroadcastFleetHistory(history []fleet.OptimisationHistoryRecord) { type historyEntry struct { - ID int64 `json:"id"` - Timestamp int64 `json:"timestamp_ms"` - TriggerReason string `json:"trigger_reason"` - MeanGDOPBefore float64 `json:"mean_gdop_before"` - MeanGDOPAfter float64 `json:"mean_gdop_after"` - CoverageDelta float64 `json:"coverage_delta"` + ID int64 `json:"id"` + Timestamp int64 `json:"timestamp_ms"` + TriggerReason string `json:"trigger_reason"` + MeanGDOPBefore float64 `json:"mean_gdop_before"` + MeanGDOPAfter float64 `json:"mean_gdop_after"` + CoverageDelta float64 `json:"coverage_delta"` } wireHistory := make([]historyEntry, len(history)) @@ -1131,11 +1131,11 @@ func (h *Hub) BroadcastSystemHealth(uptimeS int64, nodeCount, beadCount, goRouti msg := map[string]interface{}{ "type": "system_health", "health": map[string]interface{}{ - "uptime_s": uptimeS, - "node_count": nodeCount, - "bead_count": beadCount, - "go_routines": goRoutines, - "mem_mb": memMB, + "uptime_s": uptimeS, + "node_count": nodeCount, + "bead_count": beadCount, + "go_routines": goRoutines, + "mem_mb": memMB, }, } data, _ := json.Marshal(msg) @@ -1149,14 +1149,14 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p msg := map[string]interface{}{ "type": "event", "event": map[string]interface{}{ - "id": id, - "ts": timestamp, - "kind": eventType, - "zone": zone, - "blob_id": blobID, - "person_name": person, - "detail_json": detailJSON, - "severity": severity, + "id": id, + "ts": timestamp, + "kind": eventType, + "zone": zone, + "blob_id": blobID, + "person_name": person, + "detail_json": detailJSON, + "severity": severity, }, } data, _ := json.Marshal(msg) @@ -1168,9 +1168,9 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p // 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", + "type": "zone_change", "action": action, - "zone": zone, + "zone": zone, } data, _ := json.Marshal(msg) h.Broadcast(data) @@ -1187,7 +1187,7 @@ func (h *Hub) BroadcastZoneChange(action string, zone ZoneSnapshot) { func (h *Hub) BroadcastPortalChange(action string, portal PortalSnapshot) { msg := map[string]interface{}{ "type": "portal_change", - "action": action, + "action": action, "portal": portal, } data, _ := json.Marshal(msg) @@ -1217,8 +1217,8 @@ func (h *Hub) BroadcastLoadState(level int, label string) { // completed sleep session exists. func (h *Hub) BroadcastMorningSummary(summary map[string]interface{}) { msg := map[string]interface{}{ - "type": "morning_summary", - "sleep": summary, + "type": "morning_summary", + "sleep": summary, } data, _ := json.Marshal(msg) h.Broadcast(data) @@ -1313,10 +1313,10 @@ func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64) // detection quality drops below 60% for over 24 hours. func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) { msg := map[string]interface{}{ - "type": "quality_drop", - "zone_id": zoneID, - "zone_name": zoneName, - "quality": quality, + "type": "quality_drop", + "zone_id": zoneID, + "zone_name": zoneName, + "quality": quality, } data, _ := json.Marshal(msg) h.Broadcast(data) @@ -1327,8 +1327,8 @@ func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) // settings key is edited 3+ times within 60 minutes. func (h *Hub) BroadcastRepeatedEdit(key string) { msg := map[string]interface{}{ - "type": "repeated_edit", - "key": key, + "type": "repeated_edit", + "key": key, } data, _ := json.Marshal(msg) h.Broadcast(data) @@ -1387,12 +1387,12 @@ func (h *Hub) BroadcastZoneOccupancy(occupancy map[string]ZoneOccupancySnapshot) // Fired when a blob crosses a portal from one zone to another. func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZone, toZone string) { msg := map[string]interface{}{ - "type": "zone_transition", - "portal_id": portalID, - "person": personLabel, - "from_zone": fromZone, - "to_zone": toZone, - "timestamp": time.Now().Format(time.RFC3339), + "type": "zone_transition", + "portal_id": portalID, + "person": personLabel, + "from_zone": fromZone, + "to_zone": toZone, + "timestamp": time.Now().Format(time.RFC3339), } data, _ := json.Marshal(msg) h.Broadcast(data) @@ -1402,12 +1402,12 @@ func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZ // Called when OTA state changes (pending, downloading, rebooting, verified, failed, rollback). func (h *Hub) BroadcastOTAProgress(mac string, state string, progressPct uint8, expectedVersion, previousVersion string, errorMsg string) { msg := map[string]interface{}{ - "type": "ota_progress", - "mac": mac, - "state": state, - "progress_pct": progressPct, - "expected_version": expectedVersion, - "previous_version": previousVersion, + "type": "ota_progress", + "mac": mac, + "state": state, + "progress_pct": progressPct, + "expected_version": expectedVersion, + "previous_version": previousVersion, } if errorMsg != "" { msg["error"] = errorMsg diff --git a/mothership/internal/dashboard/hub_test.go b/mothership/internal/dashboard/hub_test.go index 8fd78c6..f0804cc 100644 --- a/mothership/internal/dashboard/hub_test.go +++ b/mothership/internal/dashboard/hub_test.go @@ -397,12 +397,12 @@ func TestHub_BroadcastAlert(t *testing.T) { func TestHub_BroadcastEvent(t *testing.T) { tests := []struct { - name string - eventID string - kind string - zone string - blobID int - personName string + name string + eventID string + kind string + zone string + blobID int + personName string }{ { name: "zone entry with person", @@ -584,46 +584,46 @@ func TestHub_BroadcastBLEScan(t *testing.T) { func TestHub_BroadcastEventFromDB(t *testing.T) { tests := []struct { - name string - id int64 - timestamp int64 - eventType string - zone string - person string - blobID int - detailJSON string - severity string + name string + id int64 + timestamp int64 + eventType string + zone string + person string + blobID int + detailJSON string + severity string }{ { - name: "zone entry with person and detail", - id: 42, - timestamp: 1711234567890, - eventType: "zone_entry", - zone: "Kitchen", - person: "Alice", - blobID: 2, + name: "zone entry with person and detail", + id: 42, + timestamp: 1711234567890, + eventType: "zone_entry", + zone: "Kitchen", + person: "Alice", + blobID: 2, detailJSON: `{"direction":"north"}`, + severity: "info", + }, + { + name: "zone exit without person", + id: 43, + timestamp: 1711234567891, + eventType: "zone_exit", + zone: "Kitchen", + person: "", + blobID: 3, severity: "info", }, { - name: "zone exit without person", - id: 43, - timestamp: 1711234567891, - eventType: "zone_exit", - zone: "Kitchen", - person: "", - blobID: 3, - severity: "info", - }, - { - name: "portal crossing", - id: 44, - timestamp: 1711234567892, - eventType: "portal_crossing", - zone: "Hallway", - person: "Bob", - blobID: 1, - severity: "info", + name: "portal crossing", + id: 44, + timestamp: 1711234567892, + eventType: "portal_crossing", + zone: "Hallway", + person: "Bob", + blobID: 1, + severity: "info", }, { name: "anomaly alert", @@ -637,14 +637,14 @@ func TestHub_BroadcastEventFromDB(t *testing.T) { severity: "warning", }, { - name: "minimal event", - id: 46, - timestamp: 1711234567894, - eventType: "system", - zone: "", - person: "", - blobID: 0, - severity: "info", + name: "minimal event", + id: 46, + timestamp: 1711234567894, + eventType: "system", + zone: "", + person: "", + blobID: 0, + severity: "info", }, } @@ -729,12 +729,12 @@ func TestHub_BroadcastEventFromDB(t *testing.T) { func TestHub_BroadcastSystemHealth(t *testing.T) { tests := []struct { - name string - uptimeS int64 - nodeCount int - beadCount int - goRoutines int - memMB float64 + name string + uptimeS int64 + nodeCount int + beadCount int + goRoutines int + memMB float64 }{ { name: "fresh start", @@ -878,32 +878,32 @@ func TestHub_DeltaOmitsTypeField(t *testing.T) { func TestHub_BroadcastTriggerState(t *testing.T) { tests := []struct { - name string - triggerID string + name string + triggerID string triggerName string - lastFired time.Time - enabled bool + lastFired time.Time + enabled bool }{ { - name: "enabled trigger with last fired", - triggerID: "trigger-1", + name: "enabled trigger with last fired", + triggerID: "trigger-1", triggerName: "Couch Dwell", - lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC), - enabled: true, + lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC), + enabled: true, }, { - name: "disabled trigger never fired", - triggerID: "trigger-2", + name: "disabled trigger never fired", + triggerID: "trigger-2", triggerName: "Hallway Motion", - lastFired: time.Time{}, - enabled: false, + lastFired: time.Time{}, + enabled: false, }, { - name: "enabled trigger never fired", - triggerID: "trigger-3", + name: "enabled trigger never fired", + triggerID: "trigger-3", triggerName: "Kitchen Entry", - lastFired: time.Time{}, - enabled: true, + lastFired: time.Time{}, + enabled: true, }, } diff --git a/mothership/internal/db/db.go b/mothership/internal/db/db.go index af7d179..7927374 100644 --- a/mothership/internal/db/db.go +++ b/mothership/internal/db/db.go @@ -19,10 +19,10 @@ import ( // OpenDB initializes the database with full startup sequence. // It runs migrations, creates backups, and returns a ready-to-use database connection. // The startup sequence is: -// 1. Data directory: verify /data is writable; acquire flock() lock -// 2. SQLite: open database with WAL mode, busy_timeout=5000 -// 3. Schema migration: apply pending migrations with backup -// 4. Config & secrets: load/generate install secret +// 1. Data directory: verify /data is writable; acquire flock() lock +// 2. SQLite: open database with WAL mode, busy_timeout=5000 +// 3. Schema migration: apply pending migrations with backup +// 4. Config & secrets: load/generate install secret // // The parentCtx should be the startup timeout context from main so that all // phases share the same 30-second deadline. If parentCtx is nil, a fresh @@ -75,7 +75,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) db.SetMaxOpenConns(1) // SQLite is single-writer if err := db.PingContext(ctx); err != nil { - db.Close() //nolint:errcheck + db.Close() //nolint:errcheck lockFile.Close() //nolint:errcheck return nil, fmt.Errorf("ping database: %w", err) } @@ -108,7 +108,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) BackupRetention: 90 * 24 * time.Hour, }) if err != nil { - db.Close() //nolint:errcheck + db.Close() //nolint:errcheck lockFile.Close() //nolint:errcheck return nil, fmt.Errorf("create migrator: %w", err) } @@ -116,7 +116,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) current, err := migrator.CurrentVersion(ctx) if err != nil { - db.Close() //nolint:errcheck + db.Close() //nolint:errcheck lockFile.Close() //nolint:errcheck return nil, fmt.Errorf("get current version: %w", err) } @@ -128,7 +128,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) } if err := migrator.Migrate(ctx); err != nil { - db.Close() //nolint:errcheck + db.Close() //nolint:errcheck lockFile.Close() //nolint:errcheck return nil, fmt.Errorf("apply migrations: %w", err) } @@ -147,7 +147,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error) startup.CheckTimeout(ctx) done = startup.Phase(4, "Config & secrets") if err := ensureInstallSecret(ctx, db); err != nil { - db.Close() //nolint:errcheck + db.Close() //nolint:errcheck lockFile.Close() //nolint:errcheck return nil, fmt.Errorf("ensure install secret: %w", err) } diff --git a/mothership/internal/db/migrate_test.go b/mothership/internal/db/migrate_test.go index 3a08572..ccf87bf 100644 --- a/mothership/internal/db/migrate_test.go +++ b/mothership/internal/db/migrate_test.go @@ -386,10 +386,10 @@ func TestPreMigrationBackup(t *testing.T) { // TestCurrentVersion tests getting the current schema version. func TestCurrentVersion(t *testing.T) { tests := []struct { - name string - setupFunc func(*sql.DB, *testing.T) error - wantVersion int - wantErr bool + name string + setupFunc func(*sql.DB, *testing.T) error + wantVersion int + wantErr bool }{ { name: "no migrations table", diff --git a/mothership/internal/diagnostics/linkweather.go b/mothership/internal/diagnostics/linkweather.go index 83b0339..d543c18 100644 --- a/mothership/internal/diagnostics/linkweather.go +++ b/mothership/internal/diagnostics/linkweather.go @@ -16,23 +16,23 @@ import ( type DiagnosisSeverity string const ( - SeverityINFO DiagnosisSeverity = "INFO" // Informational, no action needed - SeverityWARNING DiagnosisSeverity = "WARNING" // Attention recommended + SeverityINFO DiagnosisSeverity = "INFO" // Informational, no action needed + SeverityWARNING DiagnosisSeverity = "WARNING" // Attention recommended SeverityACTIONABLE DiagnosisSeverity = "ACTIONABLE" // Specific action required ) // Diagnosis represents a diagnostic finding for a link type Diagnosis struct { - LinkID string - RuleID string // Identifies which rule fired - Severity DiagnosisSeverity // INFO, WARNING, ACTIONABLE - Title string // Human-readable headline - Detail string // Explanation in plain language - Advice string // Specific actionable steps - RepositioningTarget *Vec3 // 3D position to move node, or nil - RepositioningNodeMAC string // Which node to move - ConfidenceScore float64 // How confident the engine is (0-1) - Timestamp time.Time + LinkID string + RuleID string // Identifies which rule fired + Severity DiagnosisSeverity // INFO, WARNING, ACTIONABLE + Title string // Human-readable headline + Detail string // Explanation in plain language + Advice string // Specific actionable steps + RepositioningTarget *Vec3 // 3D position to move node, or nil + RepositioningNodeMAC string // Which node to move + ConfidenceScore float64 // How confident the engine is (0-1) + Timestamp time.Time } // Vec3 represents a 3D position @@ -44,21 +44,21 @@ type Vec3 struct { // LinkHealthSnapshot represents a health snapshot for diagnostic analysis type LinkHealthSnapshot struct { - Timestamp time.Time - SNR float64 - PhaseStability float64 - PacketRate float64 - DriftRate float64 - CompositeScore float64 + Timestamp time.Time + SNR float64 + PhaseStability float64 + PacketRate float64 + DriftRate float64 + CompositeScore float64 DeltaRMSVariance float64 // For periodic interference detection - IsQuietPeriod bool // True if no motion detected + IsQuietPeriod bool // True if no motion detected } // FeedbackEvent represents user-reported false negative/positive for Rule 4 type FeedbackEvent struct { LinkID string - EventType string // "false_negative" or "false_positive" - Position Vec3 // Where the event occurred + EventType string // "false_negative" or "false_positive" + Position Vec3 // Where the event occurred Timestamp time.Time } @@ -324,15 +324,15 @@ func (de *DiagnosticEngine) checkEnvironmentalChange(linkID string, history []Li confidence := 0.85 return &Diagnosis{ - LinkID: linkID, - RuleID: "environmental_change", - Severity: SeverityINFO, - Title: "Environmental change detected", - Detail: "Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.", - Advice: "No action needed. The baseline will re-stabilise within 30 minutes.", + LinkID: linkID, + RuleID: "environmental_change", + Severity: SeverityINFO, + Title: "Environmental change detected", + Detail: "Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.", + Advice: "No action needed. The baseline will re-stabilise within 30 minutes.", RepositioningTarget: nil, - ConfidenceScore: confidence, - Timestamp: time.Now(), + ConfidenceScore: confidence, + Timestamp: time.Now(), } } @@ -391,16 +391,16 @@ func (de *DiagnosticEngine) checkWiFiCongestion(linkID string, history []LinkHea nodeBMAC := extractNodeBMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "wifi_congestion_distance", - Severity: SeverityACTIONABLE, - Title: "Node has low signal rate", - Detail: formatWiFiDetail(nodeBMAC, avgPacketRate), - Advice: formatWiFiAdvice(nodeBMAC), - RepositioningTarget: nil, + LinkID: linkID, + RuleID: "wifi_congestion_distance", + Severity: SeverityACTIONABLE, + Title: "Node has low signal rate", + Detail: formatWiFiDetail(nodeBMAC, avgPacketRate), + Advice: formatWiFiAdvice(nodeBMAC), + RepositioningTarget: nil, RepositioningNodeMAC: nodeBMAC, - ConfidenceScore: 0.75, - Timestamp: time.Now(), + ConfidenceScore: 0.75, + Timestamp: time.Now(), } } @@ -461,16 +461,16 @@ func (de *DiagnosticEngine) checkMetalInterference(linkID string, history []Link nodeAMAC := extractNodeAMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "metal_interference", - Severity: SeverityACTIONABLE, - Title: formatMetalTitle(nodeAMAC), - Detail: formatMetalDetail(linkID), - Advice: formatMetalAdvice(nodeAMAC), - RepositioningTarget: nil, + LinkID: linkID, + RuleID: "metal_interference", + Severity: SeverityACTIONABLE, + Title: formatMetalTitle(nodeAMAC), + Detail: formatMetalDetail(linkID), + Advice: formatMetalAdvice(nodeAMAC), + RepositioningTarget: nil, RepositioningNodeMAC: nodeAMAC, - ConfidenceScore: 0.80, - Timestamp: time.Now(), + ConfidenceScore: 0.80, + Timestamp: time.Now(), } } @@ -538,16 +538,16 @@ func (de *DiagnosticEngine) checkFresnelBlockage(linkID string, history []LinkHe advice := formatFresnelAdvice(nodeBMAC, &blockedZone, target, improvement) return &Diagnosis{ - LinkID: linkID, - RuleID: "fresnel_blockage", - Severity: SeverityACTIONABLE, - Title: "Coverage gap detected - possible obstruction", - Detail: detail, - Advice: advice, - RepositioningTarget: target, + LinkID: linkID, + RuleID: "fresnel_blockage", + Severity: SeverityACTIONABLE, + Title: "Coverage gap detected - possible obstruction", + Detail: detail, + Advice: advice, + RepositioningTarget: target, RepositioningNodeMAC: targetNodeMAC, - ConfidenceScore: 0.75, - Timestamp: time.Now(), + ConfidenceScore: 0.75, + Timestamp: time.Now(), } } @@ -582,16 +582,16 @@ func (de *DiagnosticEngine) checkFresnelBlockageHeuristic(linkID string, history if avgActivity < 0.5 && avgActivity < avgQuiet-0.2 { nodeBMAC := extractNodeBMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "fresnel_blockage_heuristic", - Severity: SeverityWARNING, - Title: "Possible coverage gap detected", - Detail: "Detection quality degrades during movement periods. This may indicate an obstruction in the sensing zone.", - Advice: "Submit feedback using the app when detection misses occur. This will help identify the exact location of the coverage gap.", - RepositioningTarget: nil, + LinkID: linkID, + RuleID: "fresnel_blockage_heuristic", + Severity: SeverityWARNING, + Title: "Possible coverage gap detected", + Detail: "Detection quality degrades during movement periods. This may indicate an obstruction in the sensing zone.", + Advice: "Submit feedback using the app when detection misses occur. This will help identify the exact location of the coverage gap.", + RepositioningTarget: nil, RepositioningNodeMAC: nodeBMAC, - ConfidenceScore: 0.60, // Lower confidence for heuristic - Timestamp: time.Now(), + ConfidenceScore: 0.60, // Lower confidence for heuristic + Timestamp: time.Now(), } } @@ -639,15 +639,15 @@ func (de *DiagnosticEngine) checkPeriodicInterference(linkID string, history []L nodeBMAC := extractNodeBMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "periodic_interference", - Severity: SeverityWARNING, - Title: "Periodic interference detected", - Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, int(eventsPerHour)), - Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC), + LinkID: linkID, + RuleID: "periodic_interference", + Severity: SeverityWARNING, + Title: "Periodic interference detected", + Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, int(eventsPerHour)), + Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC), RepositioningTarget: nil, - ConfidenceScore: 0.70, - Timestamp: time.Now(), + ConfidenceScore: 0.70, + Timestamp: time.Now(), } } @@ -1036,14 +1036,14 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time) if len(history) < de.config.MinSamples { // Not enough data - return a generic diagnosis return &Diagnosis{ - LinkID: linkID, - RuleID: "insufficient_data", - Severity: SeverityINFO, - Title: "Not enough data for diagnosis", - Detail: "This link hasn't been active long enough to analyze.", - Advice: "Continue normal operation. Diagnostics will be available after more data is collected.", - ConfidenceScore: 0.0, - Timestamp: time.Now(), + LinkID: linkID, + RuleID: "insufficient_data", + Severity: SeverityINFO, + Title: "Not enough data for diagnosis", + Detail: "This link hasn't been active long enough to analyze.", + Advice: "Continue normal operation. Diagnostics will be available after more data is collected.", + ConfidenceScore: 0.0, + Timestamp: time.Now(), } } @@ -1072,14 +1072,14 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time) // Rule: Check for environmental change indicators if closestSnapshot.DriftRate > 0.05 { return &Diagnosis{ - LinkID: linkID, - RuleID: "environmental_change", - Severity: SeverityINFO, - Title: "Possible environmental change", - Detail: "The baseline for this link has drifted significantly. This could be caused by temperature changes, furniture movement, or new obstructions.", - Advice: "The system is adapting automatically. No action needed unless this persists.", + LinkID: linkID, + RuleID: "environmental_change", + Severity: SeverityINFO, + Title: "Possible environmental change", + Detail: "The baseline for this link has drifted significantly. This could be caused by temperature changes, furniture movement, or new obstructions.", + Advice: "The system is adapting automatically. No action needed unless this persists.", ConfidenceScore: 0.70, - Timestamp: time.Now(), + Timestamp: time.Now(), } } @@ -1087,15 +1087,15 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time) if closestSnapshot.PacketRate < 16.0 { // Less than 80% of expected 20 Hz nodeBMAC := extractNodeBMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "wifi_congestion", - Severity: SeverityACTIONABLE, - Title: "Possible WiFi congestion", - Detail: formatWiFiDetail(nodeBMAC, closestSnapshot.PacketRate), - Advice: formatWiFiAdvice(nodeBMAC), + LinkID: linkID, + RuleID: "wifi_congestion", + Severity: SeverityACTIONABLE, + Title: "Possible WiFi congestion", + Detail: formatWiFiDetail(nodeBMAC, closestSnapshot.PacketRate), + Advice: formatWiFiAdvice(nodeBMAC), RepositioningNodeMAC: nodeBMAC, - ConfidenceScore: 0.75, - Timestamp: time.Now(), + ConfidenceScore: 0.75, + Timestamp: time.Now(), } } @@ -1103,15 +1103,15 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time) if closestSnapshot.PhaseStability > 0.6 { nodeAMAC := extractNodeAMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "metal_interference", - Severity: SeverityACTIONABLE, - Title: formatMetalTitle(nodeAMAC), - Detail: formatMetalDetail(linkID), - Advice: formatMetalAdvice(nodeAMAC), + LinkID: linkID, + RuleID: "metal_interference", + Severity: SeverityACTIONABLE, + Title: formatMetalTitle(nodeAMAC), + Detail: formatMetalDetail(linkID), + Advice: formatMetalAdvice(nodeAMAC), RepositioningNodeMAC: nodeAMAC, - ConfidenceScore: 0.80, - Timestamp: time.Now(), + ConfidenceScore: 0.80, + Timestamp: time.Now(), } } @@ -1120,27 +1120,27 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time) nodeAMAC := extractNodeAMAC(linkID) nodeBMAC := extractNodeBMAC(linkID) return &Diagnosis{ - LinkID: linkID, - RuleID: "periodic_interference", - Severity: SeverityWARNING, - Title: "Possible periodic interference", - Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, 3), - Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC), + LinkID: linkID, + RuleID: "periodic_interference", + Severity: SeverityWARNING, + Title: "Possible periodic interference", + Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, 3), + Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC), ConfidenceScore: 0.70, - Timestamp: time.Now(), + Timestamp: time.Now(), } } // Default: no specific issue found return &Diagnosis{ - LinkID: linkID, - RuleID: "no_issue_detected", - Severity: SeverityINFO, - Title: "No specific issue detected", - Detail: "The link health metrics appear normal. The false positive may be due to transient RF interference or a brief environmental change.", - Advice: "This is a rare occurrence. If it happens frequently, consider submitting feedback so the system can learn.", + LinkID: linkID, + RuleID: "no_issue_detected", + Severity: SeverityINFO, + Title: "No specific issue detected", + Detail: "The link health metrics appear normal. The false positive may be due to transient RF interference or a brief environmental change.", + Advice: "This is a rare occurrence. If it happens frequently, consider submitting feedback so the system can learn.", ConfidenceScore: 0.50, - Timestamp: time.Now(), + Timestamp: time.Now(), } } diff --git a/mothership/internal/diagnostics/linkweather_test.go b/mothership/internal/diagnostics/linkweather_test.go index c8b066d..bb07ffa 100644 --- a/mothership/internal/diagnostics/linkweather_test.go +++ b/mothership/internal/diagnostics/linkweather_test.go @@ -710,11 +710,11 @@ func TestGetAllDiagnoses(t *testing.T) { // TestGetDiagnosticFor tests point-in-time diagnostic lookup func TestGetDiagnosticFor(t *testing.T) { tests := []struct { - name string - linkID string - setupHistory func() []LinkHealthSnapshot - wantRuleID string - wantSeverity DiagnosisSeverity + name string + linkID string + setupHistory func() []LinkHealthSnapshot + wantRuleID string + wantSeverity DiagnosisSeverity }{ { name: "environmental_change_from_high_drift", @@ -881,7 +881,7 @@ func TestGetDiagnosticFor_PriorityOrder(t *testing.T) { SNR: 0.7, PhaseStability: 0.3, PacketRate: 10.0, // Low - DriftRate: 0.08, // High + DriftRate: 0.08, // High } } diff --git a/mothership/internal/diagnostics/reposition.go b/mothership/internal/diagnostics/reposition.go index 9c6fc1c..7ad477a 100644 --- a/mothership/internal/diagnostics/reposition.go +++ b/mothership/internal/diagnostics/reposition.go @@ -104,7 +104,7 @@ func (rc *RepositioningComputer) ComputeRepositioningTarget(linkID string, block bestPos := currentPos bestImprovement := 0.0 - step := 0.3 // 30cm steps + step := 0.3 // 30cm steps minDist := 0.5 // Minimum distance from other nodes for x := originX + step; x < originX+width-step; x += step { @@ -167,8 +167,8 @@ func (rc *RepositioningComputer) computeWorstGDOPNear(fixedPositions []NodePosit for row := 0; row < rows; row++ { for col := 0; col < cols; col++ { // Convert cell to room coordinates - cellX := originX + (float64(col) + 0.5) * cellWidth - cellZ := originZ + (float64(row) + 0.5) * cellDepth + cellX := originX + (float64(col)+0.5)*cellWidth + cellZ := originZ + (float64(row)+0.5)*cellDepth // Check distance from center dist := math.Sqrt((cellX-center.X)*(cellX-center.X) + (cellZ-center.Z)*(cellZ-center.Z)) diff --git a/mothership/internal/diskspace/monitor.go b/mothership/internal/diskspace/monitor.go index 31f1323..0af9f76 100644 --- a/mothership/internal/diskspace/monitor.go +++ b/mothership/internal/diskspace/monitor.go @@ -65,14 +65,14 @@ type Monitor struct { interval time.Duration // Components to control - recorder WritePauser // CSI replay buffer - flowAccumulator WritePauser // Crowd flow accumulation - predictor UpdatePauser // Prediction model updates + recorder WritePauser // CSI replay buffer + flowAccumulator WritePauser // Crowd flow accumulation + predictor UpdatePauser // Prediction model updates // State - state State - freeMB uint64 - lastCheck time.Time + state State + freeMB uint64 + lastCheck time.Time // Lifecycle ctx context.Context @@ -295,11 +295,11 @@ func (m *Monitor) GetState() (state State, freeMB uint64, lastCheck time.Time) { // Stats returns disk-space statistics for the dashboard. type Stats struct { - State State `json:"state"` - FreeMB uint64 `json:"free_mb"` - WarningMB uint64 `json:"warning_mb"` - CriticalMB uint64 `json:"critical_mb"` - LastCheck time.Time `json:"last_check"` + State State `json:"state"` + FreeMB uint64 `json:"free_mb"` + WarningMB uint64 `json:"warning_mb"` + CriticalMB uint64 `json:"critical_mb"` + LastCheck time.Time `json:"last_check"` } // GetStats returns current statistics for dashboard display. diff --git a/mothership/internal/diskspace/monitor_test.go b/mothership/internal/diskspace/monitor_test.go index 80049c6..97ccfcd 100644 --- a/mothership/internal/diskspace/monitor_test.go +++ b/mothership/internal/diskspace/monitor_test.go @@ -12,8 +12,8 @@ import ( // mockWritePauser is a test mock for WritePauser. type mockWritePauser struct { - paused atomic.Bool - pauseCnt atomic.Int32 + paused atomic.Bool + pauseCnt atomic.Int32 resumeCnt atomic.Int32 } @@ -41,8 +41,8 @@ func (m *mockWritePauser) ResumeCount() int { // mockUpdatePauser is a test mock for UpdatePauser. type mockUpdatePauser struct { - paused atomic.Bool - pauseCnt atomic.Int32 + paused atomic.Bool + pauseCnt atomic.Int32 resumeCnt atomic.Int32 } @@ -311,11 +311,11 @@ func TestMonitor_NilComponents(t *testing.T) { // Create monitor with nil components m := New(Config{ - DataDir: tmpDir, - CheckInterval: time.Second, - Recorder: nil, + DataDir: tmpDir, + CheckInterval: time.Second, + Recorder: nil, FlowAccumulator: nil, - Predictor: nil, + Predictor: nil, }) // Should not panic diff --git a/mothership/internal/doctor/doctor.go b/mothership/internal/doctor/doctor.go index 4194f95..7112b6b 100644 --- a/mothership/internal/doctor/doctor.go +++ b/mothership/internal/doctor/doctor.go @@ -72,7 +72,7 @@ type CheckResult struct { // Response is the doctor endpoint response. type Response struct { Checks []CheckResult `json:"checks"` - Overall string `json:"overall"` // "ok", "warn", "error" + Overall string `json:"overall"` // "ok", "warn", "error" CheckedAt string `json:"checked_at"` } diff --git a/mothership/internal/doctor/doctor_test.go b/mothership/internal/doctor/doctor_test.go index bc617b3..f224c98 100644 --- a/mothership/internal/doctor/doctor_test.go +++ b/mothership/internal/doctor/doctor_test.go @@ -105,11 +105,11 @@ func TestCheckDataDirWritable(t *testing.T) { func TestCheckFirmwareDir(t *testing.T) { tests := []struct { - name string + name string setupFirmware func() string - wantName string - wantStatus string - wantMessage string + wantName string + wantStatus string + wantMessage string }{ { name: "has firmware binaries", @@ -349,9 +349,9 @@ func TestCheckInstallSecret(t *testing.T) { func TestCheckPINConfigured(t *testing.T) { tests := []struct { - name string - dbSetup func(*sql.DB) - wantStatus string + name string + dbSetup func(*sql.DB) + wantStatus string wantMessage string }{ { @@ -436,8 +436,8 @@ func TestCheckNodeTokenConsistency(t *testing.T) { wantMessage: "Cannot check node tokens", }, { - name: "nil getNodes function", - getNodes: nil, + name: "nil getNodes function", + getNodes: nil, wantStatus: "ok", }, } @@ -462,8 +462,8 @@ func TestCheckNodeTokenConsistency(t *testing.T) { func TestCheckOverall(t *testing.T) { tests := []struct { - name string - checks []CheckResult + name string + checks []CheckResult wantOverall string }{ { diff --git a/mothership/internal/eventbus/eventbus.go b/mothership/internal/eventbus/eventbus.go index 6abf0bb..6578081 100644 --- a/mothership/internal/eventbus/eventbus.go +++ b/mothership/internal/eventbus/eventbus.go @@ -8,19 +8,19 @@ import ( // Event type constants. These match the EventType values from the events package. const ( - TypeDetection = "detection" - TypeZoneEntry = "zone_entry" - TypeZoneExit = "zone_exit" - TypePortalCrossing = "portal_crossing" - TypeTriggerFired = "trigger_fired" - TypeFallAlert = "fall_alert" - TypeAnomaly = "anomaly" - TypeSecurityAlert = "security_alert" - TypeNodeOnline = "node_online" - TypeNodeOffline = "node_offline" - TypeOTAUpdate = "ota_update" - TypeBaselineChanged = "baseline_changed" - TypeSystem = "system" + TypeDetection = "detection" + TypeZoneEntry = "zone_entry" + TypeZoneExit = "zone_exit" + TypePortalCrossing = "portal_crossing" + TypeTriggerFired = "trigger_fired" + TypeFallAlert = "fall_alert" + TypeAnomaly = "anomaly" + TypeSecurityAlert = "security_alert" + TypeNodeOnline = "node_online" + TypeNodeOffline = "node_offline" + TypeOTAUpdate = "ota_update" + TypeBaselineChanged = "baseline_changed" + TypeSystem = "system" TypeLearningMilestone = "learning_milestone" ) diff --git a/mothership/internal/eventbus/eventbus_test.go b/mothership/internal/eventbus/eventbus_test.go index 664b7b4..594261c 100644 --- a/mothership/internal/eventbus/eventbus_test.go +++ b/mothership/internal/eventbus/eventbus_test.go @@ -128,9 +128,9 @@ func TestSeverityConstants(t *testing.T) { // TestEventFields verifies event struct fields work correctly. func TestEventFields(t *testing.T) { tests := []struct { - name string - event Event - check func(Event) error + name string + event Event + check func(Event) error }{ { name: "full event", diff --git a/mothership/internal/events/bus.go b/mothership/internal/events/bus.go index 64bb135..4ba030e 100644 --- a/mothership/internal/events/bus.go +++ b/mothership/internal/events/bus.go @@ -63,7 +63,7 @@ type MotionDetectedPayload struct { } func (m MotionDetectedPayload) EventType() BusEventType { return BusMotionDetected } -func (m MotionDetectedPayload) GetTimestamp() time.Time { return m.Timestamp } +func (m MotionDetectedPayload) GetTimestamp() time.Time { return m.Timestamp } // MotionStoppedPayload is emitted when motion ceases in a zone. type MotionStoppedPayload struct { @@ -77,7 +77,7 @@ type MotionStoppedPayload struct { } func (m MotionStoppedPayload) EventType() BusEventType { return BusMotionStopped } -func (m MotionStoppedPayload) GetTimestamp() time.Time { return m.Timestamp } +func (m MotionStoppedPayload) GetTimestamp() time.Time { return m.Timestamp } // ZoneTransitionPayload is emitted when a blob crosses a portal between zones. type ZoneTransitionPayload struct { @@ -96,7 +96,7 @@ type ZoneTransitionPayload struct { } func (z ZoneTransitionPayload) EventType() BusEventType { return BusZoneTransition } -func (z ZoneTransitionPayload) GetTimestamp() time.Time { return z.Timestamp } +func (z ZoneTransitionPayload) GetTimestamp() time.Time { return z.Timestamp } // ZoneEntryPayload is emitted when a blob enters a zone (not via portal). type ZoneEntryPayload struct { @@ -110,7 +110,7 @@ type ZoneEntryPayload struct { } func (z ZoneEntryPayload) EventType() BusEventType { return BusZoneEntry } -func (z ZoneEntryPayload) GetTimestamp() time.Time { return z.Timestamp } +func (z ZoneEntryPayload) GetTimestamp() time.Time { return z.Timestamp } // ZoneExitPayload is emitted when a blob exits a zone (not via portal). type ZoneExitPayload struct { @@ -124,7 +124,7 @@ type ZoneExitPayload struct { } func (z ZoneExitPayload) EventType() BusEventType { return BusZoneExit } -func (z ZoneExitPayload) GetTimestamp() time.Time { return z.Timestamp } +func (z ZoneExitPayload) GetTimestamp() time.Time { return z.Timestamp } // FallDetectedPayload is emitted when a potential fall is detected. type FallDetectedPayload struct { @@ -140,7 +140,7 @@ type FallDetectedPayload struct { } func (f FallDetectedPayload) EventType() BusEventType { return BusFallDetected } -func (f FallDetectedPayload) GetTimestamp() time.Time { return f.Timestamp } +func (f FallDetectedPayload) GetTimestamp() time.Time { return f.Timestamp } // FallConfirmedPayload is emitted when a fall is confirmed after the confirmation window. type FallConfirmedPayload struct { @@ -156,7 +156,7 @@ type FallConfirmedPayload struct { } func (f FallConfirmedPayload) EventType() BusEventType { return BusFallConfirmed } -func (f FallConfirmedPayload) GetTimestamp() time.Time { return f.Timestamp } +func (f FallConfirmedPayload) GetTimestamp() time.Time { return f.Timestamp } // NodeConnectedPayload is emitted when a node connects for the first time or after a long absence. type NodeConnectedPayload struct { @@ -168,7 +168,7 @@ type NodeConnectedPayload struct { } func (n NodeConnectedPayload) EventType() BusEventType { return BusNodeConnected } -func (n NodeConnectedPayload) GetTimestamp() time.Time { return n.Timestamp } +func (n NodeConnectedPayload) GetTimestamp() time.Time { return n.Timestamp } // NodeDisconnectedPayload is emitted when a node disconnects unexpectedly. type NodeDisconnectedPayload struct { @@ -180,7 +180,7 @@ type NodeDisconnectedPayload struct { } func (n NodeDisconnectedPayload) EventType() BusEventType { return BusNodeDisconnected } -func (n NodeDisconnectedPayload) GetTimestamp() time.Time { return n.Timestamp } +func (n NodeDisconnectedPayload) GetTimestamp() time.Time { return n.Timestamp } // NodeReconnectedPayload is emitted when a node reconnects after a brief disconnection. type NodeReconnectedPayload struct { @@ -191,7 +191,7 @@ type NodeReconnectedPayload struct { } func (n NodeReconnectedPayload) EventType() BusEventType { return BusNodeReconnected } -func (n NodeReconnectedPayload) GetTimestamp() time.Time { return n.Timestamp } +func (n NodeReconnectedPayload) GetTimestamp() time.Time { return n.Timestamp } // NodeStalePayload is emitted when a node hasn't sent health updates within the expected interval. type NodeStalePayload struct { @@ -202,7 +202,7 @@ type NodeStalePayload struct { } func (n NodeStalePayload) EventType() BusEventType { return BusNodeStale } -func (n NodeStalePayload) GetTimestamp() time.Time { return n.Timestamp } +func (n NodeStalePayload) GetTimestamp() time.Time { return n.Timestamp } // SystemStartedPayload is emitted when the mothership completes startup. type SystemStartedPayload struct { @@ -213,7 +213,7 @@ type SystemStartedPayload struct { } func (s SystemStartedPayload) EventType() BusEventType { return BusSystemStarted } -func (s SystemStartedPayload) GetTimestamp() time.Time { return s.Timestamp } +func (s SystemStartedPayload) GetTimestamp() time.Time { return s.Timestamp } // SystemShutdownPayload is emitted when the mothership begins graceful shutdown. type SystemShutdownPayload struct { @@ -223,7 +223,7 @@ type SystemShutdownPayload struct { } func (s SystemShutdownPayload) EventType() BusEventType { return BusSystemShutdown } -func (s SystemShutdownPayload) GetTimestamp() time.Time { return s.Timestamp } +func (s SystemShutdownPayload) GetTimestamp() time.Time { return s.Timestamp } // ConfigChangedPayload is emitted when a configuration value changes. type ConfigChangedPayload struct { @@ -235,7 +235,7 @@ type ConfigChangedPayload struct { } func (c ConfigChangedPayload) EventType() BusEventType { return BusConfigChanged } -func (c ConfigChangedPayload) GetTimestamp() time.Time { return c.Timestamp } +func (c ConfigChangedPayload) GetTimestamp() time.Time { return c.Timestamp } // TriggerFiredPayload is emitted when an automation trigger condition is met. type TriggerFiredPayload struct { @@ -253,7 +253,7 @@ type TriggerFiredPayload struct { } func (t TriggerFiredPayload) EventType() BusEventType { return BusTriggerFired } -func (t TriggerFiredPayload) GetTimestamp() time.Time { return t.Timestamp } +func (t TriggerFiredPayload) GetTimestamp() time.Time { return t.Timestamp } // TriggerClearedPayload is emitted when a trigger condition is no longer met. type TriggerClearedPayload struct { @@ -264,19 +264,19 @@ type TriggerClearedPayload struct { } func (t TriggerClearedPayload) EventType() BusEventType { return BusTriggerCleared } -func (t TriggerClearedPayload) GetTimestamp() time.Time { return t.Timestamp } +func (t TriggerClearedPayload) GetTimestamp() time.Time { return t.Timestamp } // BaselineUpdatedPayload is emitted when a link baseline is updated. type BaselineUpdatedPayload struct { - Timestamp time.Time `json:"timestamp"` - LinkID string `json:"link_id"` - Reason string `json:"reason"` // "manual", "drift", "schedule" - Confidence float64 `json:"confidence"` - SampleCount int `json:"sample_count"` + Timestamp time.Time `json:"timestamp"` + LinkID string `json:"link_id"` + Reason string `json:"reason"` // "manual", "drift", "schedule" + Confidence float64 `json:"confidence"` + SampleCount int `json:"sample_count"` } func (b BaselineUpdatedPayload) EventType() BusEventType { return BusBaselineUpdated } -func (b BaselineUpdatedPayload) GetTimestamp() time.Time { return b.Timestamp } +func (b BaselineUpdatedPayload) GetTimestamp() time.Time { return b.Timestamp } // ModelUpdatedPayload is emitted when a prediction model is updated. type ModelUpdatedPayload struct { @@ -290,14 +290,14 @@ type ModelUpdatedPayload struct { } func (m ModelUpdatedPayload) EventType() BusEventType { return BusModelUpdated } -func (m ModelUpdatedPayload) GetTimestamp() time.Time { return m.Timestamp } +func (m ModelUpdatedPayload) GetTimestamp() time.Time { return m.Timestamp } // EventBus provides a typed publish/subscribe mechanism for internal events. // It supports multiple subscribers per event type with fan-out delivery. type EventBus struct { - mu sync.RWMutex + mu sync.RWMutex subscribers map[BusEventType][]chan EventPayload - capacity int // Buffer capacity for subscriber channels + capacity int // Buffer capacity for subscriber channels } // NewEventBus creates a new EventBus with the specified channel buffer capacity. @@ -306,7 +306,7 @@ type EventBus struct { func NewEventBus(capacity int) *EventBus { return &EventBus{ subscribers: make(map[BusEventType][]chan EventPayload), - capacity: capacity, + capacity: capacity, } } diff --git a/mothership/internal/events/bus_test.go b/mothership/internal/events/bus_test.go index 2c6b010..8142009 100644 --- a/mothership/internal/events/bus_test.go +++ b/mothership/internal/events/bus_test.go @@ -484,9 +484,9 @@ func TestEventBusConcurrentPublish(t *testing.T) { // TestAllPayloadTypes verifies that all defined event types have a corresponding payload struct. func TestAllPayloadTypes(t *testing.T) { payloads := []struct { - name string + name string eventType BusEventType - payload EventPayload + payload EventPayload }{ {"MotionDetected", BusMotionDetected, MotionDetectedPayload{Timestamp: time.Now()}}, {"MotionStopped", BusMotionStopped, MotionStoppedPayload{Timestamp: time.Now()}}, diff --git a/mothership/internal/events/events.go b/mothership/internal/events/events.go index d7d6de5..06a8f57 100644 --- a/mothership/internal/events/events.go +++ b/mothership/internal/events/events.go @@ -18,22 +18,22 @@ import ( type EventType string const ( - EventTypeDetection EventType = "detection" - EventTypeZoneEntry EventType = "zone_entry" - EventTypeZoneExit EventType = "zone_exit" - EventTypePortalCrossing EventType = "portal_crossing" - EventTypeTriggerFired EventType = "trigger_fired" - EventTypeFallAlert EventType = "fall_alert" - EventTypeAnomaly EventType = "anomaly" - EventTypeSecurityAlert EventType = "security_alert" - EventTypeNodeOnline EventType = "node_online" - EventTypeNodeOffline EventType = "node_offline" - EventTypeOTAUpdate EventType = "ota_update" - EventTypeBaselineChanged EventType = "baseline_changed" - EventTypeSystem EventType = "system" + EventTypeDetection EventType = "detection" + EventTypeZoneEntry EventType = "zone_entry" + EventTypeZoneExit EventType = "zone_exit" + EventTypePortalCrossing EventType = "portal_crossing" + EventTypeTriggerFired EventType = "trigger_fired" + EventTypeFallAlert EventType = "fall_alert" + EventTypeAnomaly EventType = "anomaly" + EventTypeSecurityAlert EventType = "security_alert" + EventTypeNodeOnline EventType = "node_online" + EventTypeNodeOffline EventType = "node_offline" + EventTypeOTAUpdate EventType = "ota_update" + EventTypeBaselineChanged EventType = "baseline_changed" + EventTypeSystem EventType = "system" EventTypeLearningMilestone EventType = "learning_milestone" - EventTypeSleepStart EventType = "sleep_session_start" - EventTypeSleepEnd EventType = "sleep_session_end" + EventTypeSleepStart EventType = "sleep_session_start" + EventTypeSleepEnd EventType = "sleep_session_end" ) // EventSeverity represents the severity level of an event. diff --git a/mothership/internal/events/events_test.go b/mothership/internal/events/events_test.go index 5d761d8..d4bb99a 100644 --- a/mothership/internal/events/events_test.go +++ b/mothership/internal/events/events_test.go @@ -109,10 +109,10 @@ func TestInsertEvent(t *testing.T) { name: "event with timestamp", event: Event{ TimestampMs: 1710000000000, - Type: EventTypeZoneEntry, - Zone: "Living Room", - Person: "Bob", - Severity: SeverityInfo, + Type: EventTypeZoneEntry, + Zone: "Living Room", + Person: "Bob", + Severity: SeverityInfo, }, wantID: true, }, @@ -427,7 +427,7 @@ func TestRunArchiveJob(t *testing.T) { defer db.Close() //nolint:errcheck now := time.Now().UnixMilli() - oldCutoff := now - ArchiveDaysMs - 1000 // Older than archive threshold + oldCutoff := now - ArchiveDaysMs - 1000 // Older than archive threshold youngCutoff := now - ArchiveDaysMs + 1000 // Newer than archive threshold // Insert old events (should be archived) @@ -753,4 +753,3 @@ func TestStartArchiveScheduler_StopsOnDone(t *testing.T) { // Test passes if we got here without deadlock } - diff --git a/mothership/internal/events/storage.go b/mothership/internal/events/storage.go index 26820e8..1a5c49b 100644 --- a/mothership/internal/events/storage.go +++ b/mothership/internal/events/storage.go @@ -232,10 +232,10 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event { case NodeConnectedPayload: base.Type = EventTypeNodeOnline base.DetailJSON = marshalDetail(map[string]interface{}{ - "node_mac": p.NodeMAC, - "node_name": p.NodeName, + "node_mac": p.NodeMAC, + "node_name": p.NodeName, "firmware_version": p.FirmwareVer, - "ip_address": p.IPAddress, + "ip_address": p.IPAddress, }) base.Severity = SeverityInfo @@ -261,8 +261,8 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event { case NodeStalePayload: base.Type = EventTypeNodeOffline base.DetailJSON = marshalDetail(map[string]interface{}{ - "node_mac": p.NodeMAC, - "node_name": p.NodeName, + "node_mac": p.NodeMAC, + "node_name": p.NodeName, "last_health_ms": p.LastHealthMs, }) base.Severity = SeverityWarning @@ -270,9 +270,9 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event { case SystemStartedPayload: base.Type = EventTypeSystem base.DetailJSON = marshalDetail(map[string]interface{}{ - "message": "System started", - "version": p.Version, - "start_time": p.StartTime.Format(time.RFC3339), + "message": "System started", + "version": p.Version, + "start_time": p.StartTime.Format(time.RFC3339), "duration_ms": p.DurationMs, }) base.Severity = SeverityInfo @@ -334,11 +334,11 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event { base.Type = EventTypeLearningMilestone base.Person = p.PersonID base.DetailJSON = marshalDetail(map[string]interface{}{ - "model_type": p.ModelType, - "zone_id": p.ZoneID, - "samples_added": p.SamplesAdded, - "total_samples": p.TotalSamples, - "accuracy_percent": p.AccuracyPercent, + "model_type": p.ModelType, + "zone_id": p.ZoneID, + "samples_added": p.SamplesAdded, + "total_samples": p.TotalSamples, + "accuracy_percent": p.AccuracyPercent, }) base.Severity = SeverityInfo @@ -425,8 +425,8 @@ func (s *StorageSubscriber) Stats() map[string]interface{} { defer s.mu.Unlock() return map[string]interface{}{ - "queue_size": len(s.queue), + "queue_size": len(s.queue), "queue_capacity": bufferSize, - "dropped_total": s.dropped, + "dropped_total": s.dropped, } } diff --git a/mothership/internal/events/storage_test.go b/mothership/internal/events/storage_test.go index 4d21586..da2a48e 100644 --- a/mothership/internal/events/storage_test.go +++ b/mothership/internal/events/storage_test.go @@ -74,12 +74,12 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { testTime := time.Now() testCases := []struct { - name string - payload EventPayload - expectedType EventType - expectedZone string - expectedPerson string - expectedBlobID int + name string + payload EventPayload + expectedType EventType + expectedZone string + expectedPerson string + expectedBlobID int expectedSeverity EventSeverity }{ { @@ -91,10 +91,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { BlobID: 1, Confidence: 0.85, }, - expectedType: EventTypeDetection, - expectedZone: "Kitchen", - expectedPerson: "Alice", - expectedBlobID: 1, + expectedType: EventTypeDetection, + expectedZone: "Kitchen", + expectedPerson: "Alice", + expectedBlobID: 1, expectedSeverity: SeverityInfo, }, { @@ -106,10 +106,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { BlobID: 2, DurationMs: 5000, }, - expectedType: EventTypeDetection, - expectedZone: "Living Room", - expectedPerson: "Bob", - expectedBlobID: 2, + expectedType: EventTypeDetection, + expectedZone: "Living Room", + expectedPerson: "Bob", + expectedBlobID: 2, expectedSeverity: SeverityInfo, }, { @@ -123,10 +123,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { BlobID: 1, Direction: "a_to_b", }, - expectedType: EventTypePortalCrossing, - expectedZone: "Kitchen", - expectedPerson: "Alice", - expectedBlobID: 1, + expectedType: EventTypePortalCrossing, + expectedZone: "Kitchen", + expectedPerson: "Alice", + expectedBlobID: 1, expectedSeverity: SeverityInfo, }, { @@ -137,10 +137,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { PersonName: "Charlie", BlobID: 3, }, - expectedType: EventTypeZoneEntry, - expectedZone: "Bedroom", - expectedPerson: "Charlie", - expectedBlobID: 3, + expectedType: EventTypeZoneEntry, + expectedZone: "Bedroom", + expectedPerson: "Charlie", + expectedBlobID: 3, expectedSeverity: SeverityInfo, }, { @@ -151,10 +151,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { PersonName: "Diana", BlobID: 4, }, - expectedType: EventTypeZoneExit, - expectedZone: "Bathroom", - expectedPerson: "Diana", - expectedBlobID: 4, + expectedType: EventTypeZoneExit, + expectedZone: "Bathroom", + expectedPerson: "Diana", + expectedBlobID: 4, expectedSeverity: SeverityInfo, }, { @@ -167,10 +167,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { ZVelocity: -2.5, Confidence: 0.95, }, - expectedType: EventTypeFallAlert, - expectedZone: "Hallway", - expectedPerson: "Eve", - expectedBlobID: 5, + expectedType: EventTypeFallAlert, + expectedZone: "Hallway", + expectedPerson: "Eve", + expectedBlobID: 5, expectedSeverity: SeverityAlert, }, { @@ -183,10 +183,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { ConfirmationMs: 10000, AlertSent: true, }, - expectedType: EventTypeFallAlert, - expectedZone: "Bathroom", - expectedPerson: "Frank", - expectedBlobID: 6, + expectedType: EventTypeFallAlert, + expectedZone: "Bathroom", + expectedPerson: "Frank", + expectedBlobID: 6, expectedSeverity: SeverityCritical, }, { @@ -198,7 +198,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { FirmwareVer: "1.0.0", IPAddress: "192.168.1.100", }, - expectedType: EventTypeNodeOnline, + expectedType: EventTypeNodeOnline, expectedSeverity: SeverityInfo, }, { @@ -210,7 +210,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { WasOnlineFor: 3600000, Reason: "timeout", }, - expectedType: EventTypeNodeOffline, + expectedType: EventTypeNodeOffline, expectedSeverity: SeverityWarning, }, { @@ -221,7 +221,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { NodeName: "Kitchen North", OfflineForMs: 5000, }, - expectedType: EventTypeNodeOnline, + expectedType: EventTypeNodeOnline, expectedSeverity: SeverityInfo, }, { @@ -232,7 +232,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { NodeName: "Bedroom", LastHealthMs: 20000, }, - expectedType: EventTypeNodeOffline, + expectedType: EventTypeNodeOffline, expectedSeverity: SeverityWarning, }, { @@ -243,7 +243,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { StartTime: testTime.Add(-1 * time.Second), DurationMs: 1000, }, - expectedType: EventTypeSystem, + expectedType: EventTypeSystem, expectedSeverity: SeverityInfo, }, { @@ -253,7 +253,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { Reason: "manual", DurationMs: 5000, }, - expectedType: EventTypeSystem, + expectedType: EventTypeSystem, expectedSeverity: SeverityInfo, }, { @@ -265,7 +265,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { NewValue: "20", ChangedBy: "api", }, - expectedType: EventTypeSystem, + expectedType: EventTypeSystem, expectedSeverity: SeverityInfo, }, { @@ -280,10 +280,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { BlobID: 1, DurationS: 35.0, }, - expectedType: EventTypeTriggerFired, - expectedZone: "Living Room", - expectedPerson: "Alice", - expectedBlobID: 1, + expectedType: EventTypeTriggerFired, + expectedZone: "Living Room", + expectedPerson: "Alice", + expectedBlobID: 1, expectedSeverity: SeverityInfo, }, { @@ -294,7 +294,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { TriggerName: "Couch Dwell", DurationS: 60.0, }, - expectedType: EventTypeTriggerFired, + expectedType: EventTypeTriggerFired, expectedSeverity: SeverityInfo, }, { @@ -306,22 +306,22 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) { Confidence: 0.85, SampleCount: 500, }, - expectedType: EventTypeBaselineChanged, + expectedType: EventTypeBaselineChanged, expectedSeverity: SeverityInfo, }, { name: "ModelUpdated", payload: ModelUpdatedPayload{ - Timestamp: testTime, - ModelType: "prediction", - PersonID: "Alice", - ZoneID: "1", - SamplesAdded: 10, - TotalSamples: 100, + Timestamp: testTime, + ModelType: "prediction", + PersonID: "Alice", + ZoneID: "1", + SamplesAdded: 10, + TotalSamples: 100, AccuracyPercent: 78.5, }, - expectedType: EventTypeLearningMilestone, - expectedPerson: "Alice", + expectedType: EventTypeLearningMilestone, + expectedPerson: "Alice", expectedSeverity: SeverityInfo, }, } diff --git a/mothership/internal/events/types.go b/mothership/internal/events/types.go index 2ae90f5..199b873 100644 --- a/mothership/internal/events/types.go +++ b/mothership/internal/events/types.go @@ -22,45 +22,45 @@ type Position struct { // AnomalyEvent represents a detected anomaly with full metadata. type AnomalyEvent struct { - ID string `json:"id"` - Type AnomalyType `json:"type"` - Score float64 `json:"score"` - Description string `json:"description"` - Timestamp time.Time `json:"timestamp"` - ZoneID string `json:"zone_id,omitempty"` - ZoneName string `json:"zone_name,omitempty"` - BlobID int `json:"blob_id,omitempty"` - PersonID string `json:"person_id,omitempty"` - PersonName string `json:"person_name,omitempty"` - DeviceMAC string `json:"device_mac,omitempty"` - DeviceName string `json:"device_name,omitempty"` - Position Position `json:"position,omitempty"` - HourOfWeek int `json:"hour_of_week,omitempty"` - ExpectedOccupancy float64 `json:"expected_occupancy,omitempty"` + ID string `json:"id"` + Type AnomalyType `json:"type"` + Score float64 `json:"score"` + Description string `json:"description"` + Timestamp time.Time `json:"timestamp"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + BlobID int `json:"blob_id,omitempty"` + PersonID string `json:"person_id,omitempty"` + PersonName string `json:"person_name,omitempty"` + DeviceMAC string `json:"device_mac,omitempty"` + DeviceName string `json:"device_name,omitempty"` + Position Position `json:"position,omitempty"` + HourOfWeek int `json:"hour_of_week,omitempty"` + ExpectedOccupancy float64 `json:"expected_occupancy,omitempty"` DwellDuration time.Duration `json:"dwell_duration,omitempty"` ExpectedDwell time.Duration `json:"expected_dwell,omitempty"` - RSSIdBm int `json:"rssi_dbm,omitempty"` - SeenBefore bool `json:"seen_before,omitempty"` - Acknowledged bool `json:"acknowledged"` - AcknowledgedAt time.Time `json:"acknowledged_at,omitempty"` - Feedback string `json:"feedback,omitempty"` - AcknowledgedBy string `json:"acknowledged_by,omitempty"` - AlertSent bool `json:"alert_sent"` - AlertSentAt time.Time `json:"alert_sent_at,omitempty"` - WebhookSent bool `json:"webhook_sent"` - WebhookSentAt time.Time `json:"webhook_sent_at,omitempty"` - EscalationSent bool `json:"escalation_sent"` - EscalationSentAt time.Time `json:"escalation_sent_at,omitempty"` + RSSIdBm int `json:"rssi_dbm,omitempty"` + SeenBefore bool `json:"seen_before,omitempty"` + Acknowledged bool `json:"acknowledged"` + AcknowledgedAt time.Time `json:"acknowledged_at,omitempty"` + Feedback string `json:"feedback,omitempty"` + AcknowledgedBy string `json:"acknowledged_by,omitempty"` + AlertSent bool `json:"alert_sent"` + AlertSentAt time.Time `json:"alert_sent_at,omitempty"` + WebhookSent bool `json:"webhook_sent"` + WebhookSentAt time.Time `json:"webhook_sent_at,omitempty"` + EscalationSent bool `json:"escalation_sent"` + EscalationSentAt time.Time `json:"escalation_sent_at,omitempty"` } // WeeklyAnomalySummary aggregates anomaly counts for the past week. type WeeklyAnomalySummary struct { - TotalAnomalies int `json:"total_anomalies"` - ByType map[AnomalyType]int `json:"by_type"` - ExpectedEvents int `json:"expected_events"` - GenuineIntrusions int `json:"genuine_intrusions"` - FalseAlarms int `json:"false_alarms"` - Unacknowledged int `json:"unacknowledged"` + TotalAnomalies int `json:"total_anomalies"` + ByType map[AnomalyType]int `json:"by_type"` + ExpectedEvents int `json:"expected_events"` + GenuineIntrusions int `json:"genuine_intrusions"` + FalseAlarms int `json:"false_alarms"` + Unacknowledged int `json:"unacknowledged"` } // SystemMode represents the current home occupancy mode. @@ -84,18 +84,18 @@ type SystemModeChangeEvent struct { // SleepSessionStartEvent is emitted when a sleep session is detected. type SleepSessionStartEvent struct { - ZoneID string `json:"zone_id"` - PersonID string `json:"person_id,omitempty"` - Timestamp time.Time `json:"timestamp"` - BlobID int `json:"blob_id,omitempty"` + ZoneID string `json:"zone_id"` + PersonID string `json:"person_id,omitempty"` + Timestamp time.Time `json:"timestamp"` + BlobID int `json:"blob_id,omitempty"` } // SleepSessionEndEvent is emitted when a sleep session ends. type SleepSessionEndEvent struct { - ZoneID string `json:"zone_id"` - PersonID string `json:"person_id,omitempty"` - StartTimestamp time.Time `json:"start_timestamp"` - EndTimestamp time.Time `json:"end_timestamp"` - DurationMin float64 `json:"duration_min"` - BlobID int `json:"blob_id,omitempty"` + ZoneID string `json:"zone_id"` + PersonID string `json:"person_id,omitempty"` + StartTimestamp time.Time `json:"start_timestamp"` + EndTimestamp time.Time `json:"end_timestamp"` + DurationMin float64 `json:"duration_min"` + BlobID int `json:"blob_id,omitempty"` } diff --git a/mothership/internal/explainability/handler.go b/mothership/internal/explainability/handler.go index 6376c02..2481be2 100644 --- a/mothership/internal/explainability/handler.go +++ b/mothership/internal/explainability/handler.go @@ -16,79 +16,79 @@ import ( // Handler provides the explainability HTTP API. type Handler struct { - mu sync.RWMutex - blobHistory map[int]*BlobExplanation // blobID -> explanation data - blobHistoryByTime map[int64]*BlobExplanation // timestamp -> explanation for feedback lookups - linkStates map[string]*LinkState // linkID -> link state - fusionResult *FusionResultSnapshot // latest fusion result + mu sync.RWMutex + blobHistory map[int]*BlobExplanation // blobID -> explanation data + blobHistoryByTime map[int64]*BlobExplanation // timestamp -> explanation for feedback lookups + linkStates map[string]*LinkState // linkID -> link state + fusionResult *FusionResultSnapshot // latest fusion result } // BlobExplanation contains all data needed to explain a blob detection. type BlobExplanation struct { - BlobID int `json:"blob_id"` - X float64 `json:"x"` - Y float64 `json:"y"` - Z float64 `json:"z"` - Confidence float64 `json:"confidence"` - Timestamp int64 `json:"timestamp_ms"` + BlobID int `json:"blob_id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + Confidence float64 `json:"confidence"` + Timestamp int64 `json:"timestamp_ms"` ContributingLinks []LinkContribution `json:"contributing_links"` - AllLinks []LinkContribution `json:"all_links"` - BLEMatch *BLEMatch `json:"ble_match,omitempty"` - FresnelZones []FresnelZone `json:"fresnel_zones"` + AllLinks []LinkContribution `json:"all_links"` + BLEMatch *BLEMatch `json:"ble_match,omitempty"` + FresnelZones []FresnelZone `json:"fresnel_zones"` } // LinkContribution describes how much a link contributed to a blob detection. type LinkContribution struct { - LinkID string `json:"link_id"` // e.g., "AA:BB:CC:DD:EE:FF" - NodeMAC string `json:"node_mac"` - PeerMAC string `json:"peer_mac"` - DeltaRMS float64 `json:"delta_rms"` - ZoneNumber int `json:"zone_number"` // Fresnel zone number at blob position - Weight float64 `json:"weight"` // Learned weight multiplier - Contributing bool `json:"contributing"` // true if deltaRMS exceeded threshold - Contribution float64 `json:"contribution"` // amount added to fusion grid at blob position + LinkID string `json:"link_id"` // e.g., "AA:BB:CC:DD:EE:FF" + NodeMAC string `json:"node_mac"` + PeerMAC string `json:"peer_mac"` + DeltaRMS float64 `json:"delta_rms"` + ZoneNumber int `json:"zone_number"` // Fresnel zone number at blob position + Weight float64 `json:"weight"` // Learned weight multiplier + Contributing bool `json:"contributing"` // true if deltaRMS exceeded threshold + Contribution float64 `json:"contribution"` // amount added to fusion grid at blob position } // BLEMatch describes a BLE device match for the blob. type BLEMatch struct { - PersonID string `json:"person_id"` - PersonLabel string `json:"person_label"` - PersonColor string `json:"person_color"` - DeviceAddr string `json:"device_addr"` - Confidence float64 `json:"confidence"` - MatchMethod string `json:"match_method"` // "ble_triangulation" or "ble_only" - ReportedByNodes []string `json:"reported_by_nodes"` - TriangulationPos *[3]float64 `json:"triangulation_pos,omitempty"` // [x, y, z] + PersonID string `json:"person_id"` + PersonLabel string `json:"person_label"` + PersonColor string `json:"person_color"` + DeviceAddr string `json:"device_addr"` + Confidence float64 `json:"confidence"` + MatchMethod string `json:"match_method"` // "ble_triangulation" or "ble_only" + ReportedByNodes []string `json:"reported_by_nodes"` + TriangulationPos *[3]float64 `json:"triangulation_pos,omitempty"` // [x, y, z] } // FresnelZone describes a Fresnel zone ellipsoid for a link. type FresnelZone struct { - LinkID string `json:"link_id"` - CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center - SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid - ZoneNumber int `json:"zone_number"` // zone number for this blob position - TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation - RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation - Lambda float64 `json:"lambda"` // WiFi wavelength in metres + LinkID string `json:"link_id"` + CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center + SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid + ZoneNumber int `json:"zone_number"` // zone number for this blob position + TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation + RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation + Lambda float64 `json:"lambda"` // WiFi wavelength in metres } // LinkState captures the current state of a link. type LinkState struct { - NodeMAC string - PeerMAC string - NodePos [3]float64 // [x, y, z] - PeerPos [3]float64 // [x, y, z] - DeltaRMS float64 - Motion bool - Weight float64 // Learned weight + NodeMAC string + PeerMAC string + NodePos [3]float64 // [x, y, z] + PeerPos [3]float64 // [x, y, z] + DeltaRMS float64 + Motion bool + Weight float64 // Learned weight HealthScore float64 } // FusionResultSnapshot captures the latest fusion result for explainability. type FusionResultSnapshot struct { - Timestamp int64 - Blobs []BlobSnapshot - GridData *GridSnapshot + Timestamp int64 + Blobs []BlobSnapshot + GridData *GridSnapshot } // BlobSnapshot is a lightweight blob representation. @@ -103,17 +103,17 @@ type BlobSnapshot struct { type GridSnapshot struct { Width, Depth, CellSize float64 OriginX, OriginZ float64 - Data []float64 // Normalised [0-1] row-major grid data + Data []float64 // Normalised [0-1] row-major grid data Rows, Cols int } // NewHandler creates a new explainability handler. func NewHandler() *Handler { return &Handler{ - blobHistory: make(map[int]*BlobExplanation), + blobHistory: make(map[int]*BlobExplanation), blobHistoryByTime: make(map[int64]*BlobExplanation), - linkStates: make(map[string]*LinkState), - fusionResult: &FusionResultSnapshot{}, + linkStates: make(map[string]*LinkState), + fusionResult: &FusionResultSnapshot{}, } } @@ -126,8 +126,8 @@ func (h *Handler) UpdateBlobs(blobs []BlobSnapshot, links []LinkState, grid *Gri // Update fusion result snapshot h.fusionResult = &FusionResultSnapshot{ Timestamp: time.Now().Unix(), - Blobs: blobs, - GridData: grid, + Blobs: blobs, + GridData: grid, } // Update link states @@ -192,10 +192,10 @@ func (h *Handler) explainBlob(w http.ResponseWriter, r *http.Request) { if !ok { // Return empty explanation for unknown blob explanation = &BlobExplanation{ - BlobID: blobID, - X: 0, - Y: 0, - Z: 0, + BlobID: blobID, + X: 0, + Y: 0, + Z: 0, Confidence: 0, } } @@ -310,10 +310,10 @@ func (h *Handler) GetExplanationForBlob(blobID int, timestamp int64) *BlobExplan // This is called by the dashboard to refresh the explainability data. func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) { var req struct { - Blobs []BlobSnapshot `json:"blobs"` - Links []LinkState `json:"links"` - GridData *GridSnapshot `json:"grid_data,omitempty"` - Identity map[int]*BLEMatch `json:"identity,omitempty"` + Blobs []BlobSnapshot `json:"blobs"` + Links []LinkState `json:"links"` + GridData *GridSnapshot `json:"grid_data,omitempty"` + Identity map[int]*BLEMatch `json:"identity,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -327,8 +327,8 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) { // Update fusion result snapshot h.fusionResult = &FusionResultSnapshot{ Timestamp: int64(req.GridData.Rows), // placeholder - Blobs: req.Blobs, - GridData: req.GridData, + Blobs: req.Blobs, + GridData: req.GridData, } // Update link states @@ -429,13 +429,13 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, _ *Gr a := (pathDirect + lambda) / 2 b := math.Sqrt(math.Max(0, a*a-(pathDirect/2)*(pathDirect/2))) explanation.FresnelZones = append(explanation.FresnelZones, FresnelZone{ - LinkID: linkID, - CenterPos: [3]float64{centerX, centerY, centerZ}, - SemiAxes: [3]float64{b, b, a}, + LinkID: linkID, + CenterPos: [3]float64{centerX, centerY, centerZ}, + SemiAxes: [3]float64{b, b, a}, ZoneNumber: zoneNumber, - TXPos: nodePos, - RXPos: peerPos, - Lambda: lambda, + TXPos: nodePos, + RXPos: peerPos, + Lambda: lambda, }) } } @@ -475,17 +475,17 @@ func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} { perLinkContribs := make([]map[string]interface{}, 0, len(exp.AllLinks)) for _, link := range exp.AllLinks { perLinkContribs = append(perLinkContribs, map[string]interface{}{ - "link_id": link.LinkID, - "tx_mac": link.NodeMAC, - "rx_mac": link.PeerMAC, - "delta_rms": link.DeltaRMS, - "zone_number": link.ZoneNumber, - "weight": link.Weight, - "learned_weight": 1.0, - "combined_weight": link.Weight, - "contribution_pct": link.Contribution * 100, + "link_id": link.LinkID, + "tx_mac": link.NodeMAC, + "rx_mac": link.PeerMAC, + "delta_rms": link.DeltaRMS, + "zone_number": link.ZoneNumber, + "weight": link.Weight, + "learned_weight": 1.0, + "combined_weight": link.Weight, + "contribution_pct": link.Contribution * 100, "fresnel_intersection_volume": 0, - "contributing": link.Contributing, + "contributing": link.Contributing, }) } @@ -493,23 +493,23 @@ func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} { fresnelZones := make([]map[string]interface{}, 0, len(exp.FresnelZones)) for _, z := range exp.FresnelZones { fresnelZones = append(fresnelZones, map[string]interface{}{ - "link_id": z.LinkID, - "tx_pos": z.TXPos[:], - "rx_pos": z.RXPos[:], - "center_pos": z.CenterPos[:], - "semi_axes": z.SemiAxes[:], + "link_id": z.LinkID, + "tx_pos": z.TXPos[:], + "rx_pos": z.RXPos[:], + "center_pos": z.CenterPos[:], + "semi_axes": z.SemiAxes[:], "zone_number": z.ZoneNumber, - "lambda": z.Lambda, + "lambda": z.Lambda, }) } snap := map[string]interface{}{ - "blob_id": exp.BlobID, - "blob_position": []float64{exp.X, exp.Y, exp.Z}, - "fusion_score": exp.Confidence, + "blob_id": exp.BlobID, + "blob_position": []float64{exp.X, exp.Y, exp.Z}, + "fusion_score": exp.Confidence, "per_link_contributions": perLinkContribs, - "fresnel_zones": fresnelZones, - "timestamp": time.UnixMilli(exp.Timestamp), + "fresnel_zones": fresnelZones, + "timestamp": time.UnixMilli(exp.Timestamp), } if exp.BLEMatch != nil { @@ -537,5 +537,5 @@ func writeJSON(w http.ResponseWriter, v interface{}) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - _, _ = w.Write(data); //nolint:errcheck + _, _ = w.Write(data) //nolint:errcheck } diff --git a/mothership/internal/falldetect/detector.go b/mothership/internal/falldetect/detector.go index e51b7f6..5cb9a68 100644 --- a/mothership/internal/falldetect/detector.go +++ b/mothership/internal/falldetect/detector.go @@ -44,12 +44,12 @@ type FallEvent struct { Identity string `json:"identity,omitempty"` ZoneID string `json:"zone_id,omitempty"` ZoneName string `json:"zone_name,omitempty"` - StartZ float64 `json:"start_z"` // Height before fall - EndZ float64 `json:"end_z"` // Height after fall - ZDrop float64 `json:"z_drop"` // Total Z drop in meters - PeakVelocity float64 `json:"peak_velocity"` // Peak downward velocity (m/s) - FallDuration float64 `json:"fall_duration"` // Time from descent to stillness (seconds) - StillnessTime float64 `json:"stillness_time"` // Seconds of stillness after fall + StartZ float64 `json:"start_z"` // Height before fall + EndZ float64 `json:"end_z"` // Height after fall + ZDrop float64 `json:"z_drop"` // Total Z drop in meters + PeakVelocity float64 `json:"peak_velocity"` // Peak downward velocity (m/s) + FallDuration float64 `json:"fall_duration"` // Time from descent to stillness (seconds) + StillnessTime float64 `json:"stillness_time"` // Seconds of stillness after fall Confidence float64 `json:"confidence"` Timestamp time.Time `json:"timestamp"` Position Position `json:"position"` @@ -67,22 +67,22 @@ type Position struct { // BlobSnapshot captures blob state at a point in time. type BlobSnapshot struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string - Timestamp time.Time - DeltaRMS float64 // Motion level from contributing links + Posture string + Timestamp time.Time + DeltaRMS float64 // Motion level from contributing links } // AlertLevel represents the escalation level of a fall alert. type AlertLevel int const ( - AlertLevelNone AlertLevel = iota - AlertLevelInitial // T+0s - AlertLevelEscalated // T+2min - AlertLevelUrgent // T+5min + AlertLevelNone AlertLevel = iota + AlertLevelInitial // T+0s + AlertLevelEscalated // T+2min + AlertLevelUrgent // T+5min ) // fallTrackState holds per-blob fall detection state. @@ -113,8 +113,8 @@ type fallTrackState struct { } type postureEntry struct { - posture string - time time.Time + posture string + time time.Time } // Config holds fall detector configuration. @@ -125,14 +125,14 @@ type Config struct { DescentWindow float64 // Rolling window for velocity (seconds), default: 0.2 // Post-fall confirmation - FloorLevelThreshold float64 // Z below this is "floor level" (m), default: 0.4 + FloorLevelThreshold float64 // Z below this is "floor level" (m), default: 0.4 StillnessMotionThreshold float64 // DeltaRMS below this is "still", default: 0.01 StillnessTimeRequired float64 // Seconds of stillness to confirm (s), default: 30 // False positive suppression - StandardDropThreshold float64 // Normal drop threshold (m), default: 0.8 - ElevatedDropThreshold float64 // Higher threshold if recent sitting/lying (m), default: 1.2 - PostureHistoryWindow float64 // How far back to check posture (s), default: 600 (10 min) + StandardDropThreshold float64 // Normal drop threshold (m), default: 0.8 + ElevatedDropThreshold float64 // Higher threshold if recent sitting/lying (m), default: 1.2 + PostureHistoryWindow float64 // How far back to check posture (s), default: 600 (10 min) // Alert timing EscalationTime1 time.Duration // Time to first escalation, default: 2min @@ -143,18 +143,18 @@ type Config struct { // DefaultConfig returns the default fall detection configuration. func DefaultConfig() Config { return Config{ - DescentVelocityThreshold: -1.5, - DescentDropThreshold: 0.8, - DescentWindow: 0.2, - FloorLevelThreshold: 0.4, - StillnessMotionThreshold: 0.01, - StillnessTimeRequired: 30.0, - StandardDropThreshold: 0.8, - ElevatedDropThreshold: 1.2, - PostureHistoryWindow: 600.0, - EscalationTime1: 2 * time.Minute, - EscalationTime2: 5 * time.Minute, - AlertCooldown: 5 * time.Minute, + DescentVelocityThreshold: -1.5, + DescentDropThreshold: 0.8, + DescentWindow: 0.2, + FloorLevelThreshold: 0.4, + StillnessMotionThreshold: 0.01, + StillnessTimeRequired: 30.0, + StandardDropThreshold: 0.8, + ElevatedDropThreshold: 1.2, + PostureHistoryWindow: 600.0, + EscalationTime1: 2 * time.Minute, + EscalationTime2: 5 * time.Minute, + AlertCooldown: 5 * time.Minute, } } @@ -164,20 +164,20 @@ type Detector struct { config Config // Per-blob tracking - trackStates map[int]*fallTrackState - blobHistory map[int][]BlobSnapshot + trackStates map[int]*fallTrackState + blobHistory map[int][]BlobSnapshot // Active fall events (for acknowledgement) activeFalls map[string]*FallEvent fallIDCounter int // Callbacks - onFall func(FallEvent) - onEscalation func(FallEvent, AlertLevel) - identityFunc func(blobID int) string - zoneFunc func(blobID int) (zoneID, zoneName string) - childrenZoneFunc func(blobID int) bool // Returns true if blob is in children's zone - deltaRMSFunc func(blobID int) float64 // Gets motion level for blob + onFall func(FallEvent) + onEscalation func(FallEvent, AlertLevel) + identityFunc func(blobID int) string + zoneFunc func(blobID int) (zoneID, zoneName string) + childrenZoneFunc func(blobID int) bool // Returns true if blob is in children's zone + deltaRMSFunc func(blobID int) float64 // Gets motion level for blob // Alert tracking recentAlerts map[int]time.Time // blobID -> last alert time @@ -247,10 +247,10 @@ func (d *Detector) SetDeltaRMSFunc(fn func(blobID int) float64) { // Update processes new blob positions. This should be called at 10Hz. func (d *Detector) Update(blobs []struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, now time.Time) { d.mu.Lock() defer d.mu.Unlock() @@ -268,10 +268,10 @@ func (d *Detector) Update(blobs []struct { // processBlob analyzes a single blob for fall patterns. func (d *Detector) processBlob(blob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, now time.Time) { // Record snapshot snapshot := BlobSnapshot{ @@ -347,10 +347,10 @@ func (d *Detector) processBlob(blob struct { // checkForDescent detects the initial rapid descent that could indicate a fall. func (d *Detector) checkForDescent(blob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, history []BlobSnapshot, state *fallTrackState, now time.Time) { if len(history) < 3 { return @@ -415,10 +415,10 @@ func (d *Detector) checkForDescent(blob struct { // checkForFallConfirmation checks if the descent is followed by stillness at floor level. func (d *Detector) checkForFallConfirmation(blob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, history []BlobSnapshot, state *fallTrackState, now time.Time) { // Check if person got up (Z rose above floor level) if blob.Z > d.config.FloorLevelThreshold+0.1 { // Small hysteresis @@ -478,10 +478,10 @@ func (d *Detector) checkForFallConfirmation(blob struct { // checkForRecovery monitors a confirmed fall for recovery (person gets up). func (d *Detector) checkForRecovery(blob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, state *fallTrackState, now time.Time) { // If person rises significantly, they've recovered if blob.Z > d.config.FloorLevelThreshold+0.3 { @@ -502,10 +502,10 @@ func (d *Detector) hasRecentSittingOrLying(state *fallTrackState) bool { // triggerFallAlert fires the fall alert and sets up escalation timers. func (d *Detector) triggerFallAlert(blob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Posture string + Posture string }, state *fallTrackState, fallDuration, stillnessTime float64, now time.Time) { // Check cooldown if lastAlert, exists := d.recentAlerts[blob.ID]; exists { diff --git a/mothership/internal/fleet/collision.go b/mothership/internal/fleet/collision.go index 29e9e2b..c8a0fdf 100644 --- a/mothership/internal/fleet/collision.go +++ b/mothership/internal/fleet/collision.go @@ -34,8 +34,8 @@ type CollisionEvent struct { // CollisionStats tracks collision statistics for a pair of TX nodes type CollisionStats struct { - TotalFrames int - Collisions int + TotalFrames int + Collisions int LastCollision time.Time LastReset time.Time } @@ -59,7 +59,7 @@ type CollisionDetector struct { lastArrival map[string]time.Time // Re-stagger state - lastRestagger time.Time + lastRestagger time.Time restaggersEnabled bool // Callback for re-stagger trigger @@ -72,13 +72,13 @@ type CollisionDetector struct { // NewCollisionDetector creates a new collision detector func NewCollisionDetector() *CollisionDetector { return &CollisionDetector{ - collisionCount: make(map[string]int), - totalFrames: make(map[string]int), - collisionHistory: make(map[string][]CollisionEvent), - txNodes: make(map[string]bool), - lastArrival: make(map[string]time.Time), + collisionCount: make(map[string]int), + totalFrames: make(map[string]int), + collisionHistory: make(map[string][]CollisionEvent), + txNodes: make(map[string]bool), + lastArrival: make(map[string]time.Time), restaggersEnabled: true, - frameArrivals: make(map[string][]time.Time), + frameArrivals: make(map[string][]time.Time), } } diff --git a/mothership/internal/fleet/fleet_test.go b/mothership/internal/fleet/fleet_test.go index 46afb3a..3fd237c 100644 --- a/mothership/internal/fleet/fleet_test.go +++ b/mothership/internal/fleet/fleet_test.go @@ -446,8 +446,8 @@ func (m *mockPersonNameProvider) GetPersonName(personID string) string { // mockModeChangeBroadcaster implements ModeChangeBroadcaster for testing. type mockModeChangeBroadcaster struct { - mu sync.Mutex - events []events.SystemModeChangeEvent + mu sync.Mutex + events []events.SystemModeChangeEvent } func (m *mockModeChangeBroadcaster) BroadcastSystemModeChange(event events.SystemModeChangeEvent) { diff --git a/mothership/internal/fleet/fleethandler.go b/mothership/internal/fleet/fleethandler.go index e1baa5a..ebd506b 100644 --- a/mothership/internal/fleet/fleethandler.go +++ b/mothership/internal/fleet/fleethandler.go @@ -106,17 +106,17 @@ func (h *FleetHandler) getFleetHealth(w http.ResponseWriter, r *http.Request) { } entries = append(entries, fleetNodeEntry{ - MAC: n.MAC, - Name: n.Name, - Role: role, - HealthScore: n.HealthScore, - Online: online, - PosX: n.PosX, - PosY: n.PosY, - PosZ: n.PosZ, + MAC: n.MAC, + Name: n.Name, + Role: role, + HealthScore: n.HealthScore, + Online: online, + PosX: n.PosX, + PosY: n.PosY, + PosZ: n.PosZ, FirmwareVersion: n.FirmwareVersion, - UptimeSeconds: uptimeSeconds, - LastSeenMs: n.LastSeenAt.UnixMilli(), + UptimeSeconds: uptimeSeconds, + LastSeenMs: n.LastSeenAt.UnixMilli(), }) } @@ -192,17 +192,17 @@ func (h *FleetHandler) getFleet(w http.ResponseWriter, r *http.Request) { } entries = append(entries, fleetNodeEntry{ - MAC: n.MAC, - Name: n.Name, - Role: role, - HealthScore: n.HealthScore, - Online: online, - PosX: n.PosX, - PosY: n.PosY, - PosZ: n.PosZ, + MAC: n.MAC, + Name: n.Name, + Role: role, + HealthScore: n.HealthScore, + Online: online, + PosX: n.PosX, + PosY: n.PosY, + PosZ: n.PosZ, FirmwareVersion: n.FirmwareVersion, - UptimeSeconds: uptimeSeconds, - LastSeenMs: n.LastSeenAt.UnixMilli(), + UptimeSeconds: uptimeSeconds, + LastSeenMs: n.LastSeenAt.UnixMilli(), }) } @@ -282,9 +282,9 @@ func (h *FleetHandler) getFleetHistory(w http.ResponseWriter, r *http.Request) { // optimiseResponse is returned after manual optimisation type optimiseResponse struct { - TriggerReason string `json:"trigger_reason"` - CoverageScore float64 `json:"coverage_score"` - MeanGDOP float64 `json:"mean_gdop"` + TriggerReason string `json:"trigger_reason"` + CoverageScore float64 `json:"coverage_score"` + MeanGDOP float64 `json:"mean_gdop"` RoleAssignments map[string]string `json:"role_assignments"` } @@ -292,9 +292,9 @@ func (h *FleetHandler) triggerOptimise(w http.ResponseWriter, r *http.Request) { result := h.healer.ManualOptimise() resp := optimiseResponse{ - TriggerReason: result.TriggerReason, - CoverageScore: result.CoverageScore, - MeanGDOP: result.MeanGDOP, + TriggerReason: result.TriggerReason, + CoverageScore: result.CoverageScore, + MeanGDOP: result.MeanGDOP, RoleAssignments: h.healer.GetCurrentRoles(), } @@ -382,8 +382,8 @@ func (h *FleetHandler) locateNode(w http.ResponseWriter, r *http.Request) { // This is handled by the ingestion server which has access to node connections // For now, return success - the actual command will be sent via WebSocket writeJSON(w, map[string]interface{}{ - "status": "sent", - "mac": mac, + "status": "sent", + "mac": mac, "duration_ms": req.DurationMS, }) } diff --git a/mothership/internal/fleet/handler.go b/mothership/internal/fleet/handler.go index 4421d70..8355261 100644 --- a/mothership/internal/fleet/handler.go +++ b/mothership/internal/fleet/handler.go @@ -132,20 +132,20 @@ type FleetNode struct { HealthScore float64 `json:"health_score"` Unpaired bool `json:"unpaired,omitempty"` // Computed fields - LastSeenMS int64 `json:"last_seen_ms"` - UptimeSeconds int64 `json:"uptime_seconds"` - PacketRate float64 `json:"packet_rate"` - ConfiguredRate int `json:"configured_rate"` - Temperature float64 `json:"temperature"` - OTAInProgress bool `json:"ota_in_progress"` + LastSeenMS int64 `json:"last_seen_ms"` + UptimeSeconds int64 `json:"uptime_seconds"` + PacketRate float64 `json:"packet_rate"` + ConfiguredRate int `json:"configured_rate"` + Temperature float64 `json:"temperature"` + OTAInProgress bool `json:"ota_in_progress"` } // fleetListResponse wraps the fleet list with migration window metadata. type fleetListResponse struct { - Nodes []FleetNode `json:"nodes"` - MigrationWindowActive bool `json:"migration_window_active"` - MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"` - MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"` + Nodes []FleetNode `json:"nodes"` + MigrationWindowActive bool `json:"migration_window_active"` + MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"` + MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"` } // listFleet returns extended node data with computed fields for the fleet page. @@ -549,9 +549,9 @@ func (h *Handler) exportConfig(w http.ResponseWriter, r *http.Request) { } config := map[string]interface{}{ - "version": 1, + "version": 1, "exported_at": time.Now().Format(time.RFC3339), - "nodes": nodes, + "nodes": nodes, } w.Header().Set("Content-Type", "application/json") @@ -614,14 +614,14 @@ func (h *Handler) updateRoom(w http.ResponseWriter, r *http.Request) { // ── System Mode endpoints ─────────────────────────────────────────────────────── type systemModeResponse struct { - Mode string `json:"mode"` - Reason string `json:"reason,omitempty"` + Mode string `json:"mode"` + Reason string `json:"reason,omitempty"` AutoAwayConfig autoAwayConfigResponse `json:"auto_away_config"` } type autoAwayConfigResponse struct { - Enabled bool `json:"enabled"` - AbsenceDurationSec int `json:"absence_duration_sec"` + Enabled bool `json:"enabled"` + AbsenceDurationSec int `json:"absence_duration_sec"` } // getSystemMode returns the current system mode. @@ -630,9 +630,9 @@ func (h *Handler) getSystemMode(w http.ResponseWriter, r *http.Request) { cfg := h.mgr.GetAutoAwayConfig() resp := systemModeResponse{ - Mode: string(mode), + Mode: string(mode), AutoAwayConfig: autoAwayConfigResponse{ - Enabled: cfg.Enabled, + Enabled: cfg.Enabled, AbsenceDurationSec: int(cfg.AbsenceDuration.Seconds()), }, } @@ -793,9 +793,9 @@ func (h *Handler) disableNode(w http.ResponseWriter, r *http.Request) { } writeJSON(w, map[string]interface{}{ - "ok": true, - "mac": mac, - "prior_role": node.Role, + "ok": true, + "mac": mac, + "prior_role": node.Role, "current_role": "idle", }) } @@ -830,10 +830,10 @@ func (h *Handler) enableNode(w http.ResponseWriter, r *http.Request) { } else { // Node isn't idle, just return current state. writeJSON(w, map[string]interface{}{ - "ok": true, - "mac": mac, + "ok": true, + "mac": mac, "current_role": node.Role, - "note": "node already enabled", + "note": "node already enabled", }) return } @@ -851,8 +851,8 @@ func (h *Handler) enableNode(w http.ResponseWriter, r *http.Request) { } writeJSON(w, map[string]interface{}{ - "ok": true, - "mac": mac, + "ok": true, + "mac": mac, "restored_role": priorRole, }) } diff --git a/mothership/internal/fleet/healer.go b/mothership/internal/fleet/healer.go index afe64ba..27dbe88 100644 --- a/mothership/internal/fleet/healer.go +++ b/mothership/internal/fleet/healer.go @@ -39,28 +39,28 @@ type CoverageResult struct { type FleetHealer struct { mu sync.RWMutex - registry *Registry - gdopCalc GDOPCalculator - notifier NodeStateNotifier - bcaster RegistryBroadcaster + registry *Registry + gdopCalc GDOPCalculator + notifier NodeStateNotifier + bcaster RegistryBroadcaster // State tracking online map[string]struct{} nodePositions map[string]NodePosition // Coverage history for before/after comparison - lastCoverage *CoverageResult + lastCoverage *CoverageResult coverageHistory []*CoverageResult - maxHistorySize int + maxHistorySize int // Healing configuration - healInterval time.Duration - minOnlineNodes int // Minimum nodes before degraded mode - degradedMode bool + healInterval time.Duration + minOnlineNodes int // Minimum nodes before degraded mode + degradedMode bool // Role optimization - optimalRoles map[string]string - txNodes []string + optimalRoles map[string]string + txNodes []string } // FleetHealerConfig holds configuration for FleetHealer @@ -649,8 +649,8 @@ func (fh *FleetHealer) GetWorstCoverageZone() (x, z, gdop float64) { room, _ := fh.registry.GetRoom() cellSize := 0.2 if room != nil { - x = room.OriginX + (float64(worstCol) + 0.5) * cellSize - z = room.OriginZ + (float64(worstRow) + 0.5) * cellSize + x = room.OriginX + (float64(worstCol)+0.5)*cellSize + z = room.OriginZ + (float64(worstRow)+0.5)*cellSize } return x, z, worstGDOP diff --git a/mothership/internal/fleet/healer_test.go b/mothership/internal/fleet/healer_test.go index cabec20..dbda9f4 100644 --- a/mothership/internal/fleet/healer_test.go +++ b/mothership/internal/fleet/healer_test.go @@ -9,10 +9,10 @@ import ( // ─── Mock GDOP Calculator ───────────────────────────────────────────────────── type mockGDOPCalculator struct { - mu sync.Mutex + mu sync.Mutex gdopMap []float32 - cols int - rows int + cols int + rows int } func newMockGDOPCalculator(gdop float32, cols, rows int) *mockGDOPCalculator { @@ -368,12 +368,12 @@ func TestGenerateCombinations(t *testing.T) { n, k int wantLen int }{ - {4, 2, 6}, // C(4,2) = 6 - {5, 2, 10}, // C(5,2) = 10 - {5, 3, 10}, // C(5,3) = 10 - {6, 3, 20}, // C(6,3) = 20 - {3, 0, 1}, // C(3,0) = 1 - {3, 3, 1}, // C(3,3) = 1 + {4, 2, 6}, // C(4,2) = 6 + {5, 2, 10}, // C(5,2) = 10 + {5, 3, 10}, // C(5,3) = 10 + {6, 3, 20}, // C(6,3) = 20 + {3, 0, 1}, // C(3,0) = 1 + {3, 3, 1}, // C(3,3) = 1 } for _, tt := range tests { diff --git a/mothership/internal/fleet/manager.go b/mothership/internal/fleet/manager.go index 8e76ee4..8cb5107 100644 --- a/mothership/internal/fleet/manager.go +++ b/mothership/internal/fleet/manager.go @@ -49,18 +49,18 @@ type PersonNameProvider interface { // BLEObservation represents a BLE RSSI observation with device info. type BLEObservation struct { - DeviceMAC string // The BLE device MAC address - NodeMAC string // The node that observed this device + DeviceMAC string // The BLE device MAC address + NodeMAC string // The node that observed this device RSSIdBm int Timestamp time.Time } // AutoAwayConfig holds configuration for auto-away detection. type AutoAwayConfig struct { - Enabled bool `json:"enabled"` - AbsenceDuration time.Duration `json:"absence_duration"` // Default: 15 minutes - AutoDisarmRSSI int `json:"auto_disarm_rssi"` // Default: -70 dBm - ManualOverridePause time.Duration `json:"manual_override_pause"` // Default: 30 minutes + Enabled bool `json:"enabled"` + AbsenceDuration time.Duration `json:"absence_duration"` // Default: 15 minutes + AutoDisarmRSSI int `json:"auto_disarm_rssi"` // Default: -70 dBm + ManualOverridePause time.Duration `json:"manual_override_pause"` // Default: 30 minutes } // DefaultAutoAwayConfig returns default auto-away configuration. @@ -106,8 +106,8 @@ type Manager struct { onModeChange func(events.SystemModeChangeEvent) // Collision detection and adaptive re-stagger - collisionDetector *CollisionDetector - collisionCheckTick time.Duration + collisionDetector *CollisionDetector + collisionCheckTick time.Duration } // NewManager creates a fleet manager backed by registry. diff --git a/mothership/internal/fleet/optimiser.go b/mothership/internal/fleet/optimiser.go index 66bf924..f91580c 100644 --- a/mothership/internal/fleet/optimiser.go +++ b/mothership/internal/fleet/optimiser.go @@ -9,9 +9,9 @@ import ( // Role constants const ( - RoleTX = "tx" - RoleRX = "rx" - RoleTXRX = "tx_rx" + RoleTX = "tx" + RoleRX = "rx" + RoleTXRX = "tx_rx" RolePassive = "passive" ) @@ -66,9 +66,9 @@ func DefaultOptimisationConfig() OptimisationConfig { // RoleOptimiser computes optimal role assignments to maximise coverage type RoleOptimiser struct { - config OptimisationConfig - gdopCalc GDOPCalculator - roomConfig RoomConfig + config OptimisationConfig + gdopCalc GDOPCalculator + roomConfig RoomConfig } // NewRoleOptimiser creates a new role optimiser @@ -90,16 +90,16 @@ func (ro *RoleOptimiser) SetRoomConfig(room RoomConfig) { // OptimiseResult contains the result of a role optimisation type OptimiseResult struct { - Assignments []RoleAssignment - Links []SensingLink - MeanGDOP float64 - CoverageScore float64 - OptimisedAt time.Time - TriggerReason string - GDOPBefore []float32 // GDOP map before (if available) - GDOPAfter []float32 // GDOP map after - GDOPCols int - GDOPRows int + Assignments []RoleAssignment + Links []SensingLink + MeanGDOP float64 + CoverageScore float64 + OptimisedAt time.Time + TriggerReason string + GDOPBefore []float32 // GDOP map before (if available) + GDOPAfter []float32 // GDOP map after + GDOPCols int + GDOPRows int } // Optimise computes the optimal role assignment for the given nodes diff --git a/mothership/internal/fleet/registry.go b/mothership/internal/fleet/registry.go index e9c8d94..1df8f60 100644 --- a/mothership/internal/fleet/registry.go +++ b/mothership/internal/fleet/registry.go @@ -29,7 +29,7 @@ type NodeRecord struct { MAC string `json:"mac"` Name string `json:"name"` Role string `json:"role"` - PreviousRole string `json:"previous_role"` // Role before disconnect, for reconnect grace period + PreviousRole string `json:"previous_role"` // Role before disconnect, for reconnect grace period WentOfflineAt time.Time `json:"went_offline_at,omitempty"` // When the node went offline PosX float64 `json:"pos_x"` PosY float64 `json:"pos_y"` diff --git a/mothership/internal/fleet/selfheal.go b/mothership/internal/fleet/selfheal.go index 7f70d38..2944a22 100644 --- a/mothership/internal/fleet/selfheal.go +++ b/mothership/internal/fleet/selfheal.go @@ -35,23 +35,23 @@ func DefaultSelfHealConfig() SelfHealConfig { // FleetChangeEvent is broadcast when the fleet configuration changes type FleetChangeEvent struct { - Type string `json:"type"` // "node_lost", "node_recovered", "reoptimised" - Timestamp time.Time `json:"timestamp"` - TriggerReason string `json:"trigger_reason"` - OfflineMAC string `json:"offline_mac,omitempty"` - RecoveredMAC string `json:"recovered_mac,omitempty"` - MeanGDOPBefore float64 `json:"mean_gdop_before"` - MeanGDOPAfter float64 `json:"mean_gdop_after"` - CoverageBefore float64 `json:"coverage_before"` - CoverageAfter float64 `json:"coverage_after"` - CoverageDelta float64 `json:"coverage_delta"` - IsDegradation bool `json:"is_degradation"` // true if coverage dropped significantly - WarningMessage string `json:"warning_message,omitempty"` - RoleAssignments map[string]string `json:"role_assignments"` - GDOPBefore []float32 `json:"gdop_before,omitempty"` // GDOP map before - GDOPAfter []float32 `json:"gdop_after,omitempty"` // GDOP map after - GDOPCols int `json:"gdop_cols"` - GDOPRows int `json:"gdop_rows"` + Type string `json:"type"` // "node_lost", "node_recovered", "reoptimised" + Timestamp time.Time `json:"timestamp"` + TriggerReason string `json:"trigger_reason"` + OfflineMAC string `json:"offline_mac,omitempty"` + RecoveredMAC string `json:"recovered_mac,omitempty"` + MeanGDOPBefore float64 `json:"mean_gdop_before"` + MeanGDOPAfter float64 `json:"mean_gdop_after"` + CoverageBefore float64 `json:"coverage_before"` + CoverageAfter float64 `json:"coverage_after"` + CoverageDelta float64 `json:"coverage_delta"` + IsDegradation bool `json:"is_degradation"` // true if coverage dropped significantly + WarningMessage string `json:"warning_message,omitempty"` + RoleAssignments map[string]string `json:"role_assignments"` + GDOPBefore []float32 `json:"gdop_before,omitempty"` // GDOP map before + GDOPAfter []float32 `json:"gdop_after,omitempty"` // GDOP map after + GDOPCols int `json:"gdop_cols"` + GDOPRows int `json:"gdop_rows"` } // OfflineNode tracks a node that has gone offline @@ -66,11 +66,11 @@ type OfflineNode struct { type SelfHealManager struct { mu sync.RWMutex - registry *Registry - optimiser *RoleOptimiser - notifier NodeStateNotifier - bcaster FleetChangeBroadcaster - gdopCalc GDOPCalculator + registry *Registry + optimiser *RoleOptimiser + notifier NodeStateNotifier + bcaster FleetChangeBroadcaster + gdopCalc GDOPCalculator config SelfHealConfig @@ -180,10 +180,10 @@ func (shm *SelfHealManager) OnNodeConnected(mac, firmware, chip string) { // Broadcast recovery event shm.broadcastEvent(FleetChangeEvent{ - Type: "node_recovered", - Timestamp: now, - TriggerReason: "grace_period_reconnect", - RecoveredMAC: mac, + Type: "node_recovered", + Timestamp: now, + TriggerReason: "grace_period_reconnect", + RecoveredMAC: mac, RoleAssignments: shm.getCurrentRoles(), }) diff --git a/mothership/internal/fleet/weather.go b/mothership/internal/fleet/weather.go index e08f3da..ee5e734 100644 --- a/mothership/internal/fleet/weather.go +++ b/mothership/internal/fleet/weather.go @@ -50,14 +50,14 @@ type LinkWeatherReport struct { Issues []LinkIssue // Trend over time - TrendSNR string // "improving", "stable", "degrading" - TrendStability string - TrendPacketRate string + TrendSNR string // "improving", "stable", "degrading" + TrendStability string + TrendPacketRate string // Weekly statistics - WeekUptimePct float64 - WeekMeanSNR float64 - WeekFalsePosRate float64 + WeekUptimePct float64 + WeekMeanSNR float64 + WeekFalsePosRate float64 // Repositioning advice RepositionAdvice string @@ -610,9 +610,9 @@ func (lwd *LinkWeatherDiagnostics) GetWeeklyTrend(linkID string) []DailyHealthSu } type dailyAccumulator struct { - snr float64 + snr float64 health float64 - count int + count int } func (d *dailyAccumulator) add(s LinkWeatherSnapshot) { diff --git a/mothership/internal/floorplan/floorplan.go b/mothership/internal/floorplan/floorplan.go index ced7e7b..5819609 100644 --- a/mothership/internal/floorplan/floorplan.go +++ b/mothership/internal/floorplan/floorplan.go @@ -29,8 +29,8 @@ const ( // Handler provides floor plan HTTP endpoints. type Handler struct { - db *sql.DB - dataDir string + db *sql.DB + dataDir string floorplanDir string } @@ -41,8 +41,8 @@ func NewHandler(db *sql.DB, dataDir string) *Handler { log.Printf("[WARN] Failed to create floorplan directory: %v", err) } return &Handler{ - db: db, - dataDir: dataDir, + db: db, + dataDir: dataDir, floorplanDir: fpDir, } } @@ -148,7 +148,7 @@ func (h *Handler) uploadImage(w http.ResponseWriter, r *http.Request) { // Return success with image URL w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ - "ok": "true", + "ok": "true", "image_url": "/floorplan/image.png", }) } @@ -176,11 +176,11 @@ func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { // calibrateRequest contains calibration point data. type calibrateRequest struct { - AX float64 `json:"ax"` - AY float64 `json:"ay"` - BX float64 `json:"bx"` - BY float64 `json:"by"` - DistanceM float64 `json:"distance_m"` + AX float64 `json:"ax"` + AY float64 `json:"ay"` + BX float64 `json:"bx"` + BY float64 `json:"by"` + DistanceM float64 `json:"distance_m"` RotationDeg float64 `json:"rotation_deg,omitempty"` } diff --git a/mothership/internal/fusion/explain.go b/mothership/internal/fusion/explain.go index ef59a13..764eedc 100644 --- a/mothership/internal/fusion/explain.go +++ b/mothership/internal/fusion/explain.go @@ -213,12 +213,12 @@ func GenerateExplainabilitySnapshot( (s.posA.Z + s.posB.Z) / 2, } fresnelZones = append(fresnelZones, ExplainFresnelZone{ - LinkID: s.lm.NodeMAC + ":" + s.lm.PeerMAC, - TXPos: [3]float64{s.posA.X, s.posA.Y, s.posA.Z}, - RXPos: [3]float64{s.posB.X, s.posB.Y, s.posB.Z}, - Center: center, + LinkID: s.lm.NodeMAC + ":" + s.lm.PeerMAC, + TXPos: [3]float64{s.posA.X, s.posA.Y, s.posA.Z}, + RXPos: [3]float64{s.posB.X, s.posB.Y, s.posB.Z}, + Center: center, SemiAxes: [3]float64{b, b, a}, // X=b, Y=b, Z=a for Three.js scale - Lambda: lambda, + Lambda: lambda, }) } snap.FresnelZones = fresnelZones diff --git a/mothership/internal/fusion/fusion.go b/mothership/internal/fusion/fusion.go index d1346e2..087eca0 100644 --- a/mothership/internal/fusion/fusion.go +++ b/mothership/internal/fusion/fusion.go @@ -52,13 +52,13 @@ type Result struct { // LinkContribution describes a link's contribution to fusion. type LinkContribution struct { - LinkID string // "node_mac:peer_mac" - NodeMAC string - PeerMAC string - DeltaRMS float64 - ZoneNum int // Fresnel zone number at the peak position - Weight float64 // Learned weight for this link - Contributing bool // Whether this link contributed to a blob + LinkID string // "node_mac:peer_mac" + NodeMAC string + PeerMAC string + DeltaRMS float64 + ZoneNum int // Fresnel zone number at the peak position + Weight float64 // Learned weight for this link + Contributing bool // Whether this link contributed to a blob } // Engine runs the multi-link 3D Fresnel zone fusion. @@ -155,13 +155,13 @@ func (e *Engine) Fuse(links []LinkMotion) *Result { activeLinks := 0 activeLinkData := make([]struct { - linkID string - nodeMAC string - peerMAC string - deltaRMS float64 - weight float64 - posA NodePosition - posB NodePosition + linkID string + nodeMAC string + peerMAC string + deltaRMS float64 + weight float64 + posA NodePosition + posB NodePosition }, 0) for _, lm := range links { @@ -190,13 +190,13 @@ func (e *Engine) Fuse(links []LinkMotion) *Result { // Store active link data for contribution tracking linkID := lm.NodeMAC + ":" + lm.PeerMAC activeLinkData = append(activeLinkData, struct { - linkID string - nodeMAC string - peerMAC string - deltaRMS float64 - weight float64 - posA NodePosition - posB NodePosition + linkID string + nodeMAC string + peerMAC string + deltaRMS float64 + weight float64 + posA NodePosition + posB NodePosition }{ linkID: linkID, nodeMAC: lm.NodeMAC, @@ -337,13 +337,13 @@ func (e *Engine) GetGridSnapshot() *explainability.GridSnapshot { data := e.grid.Snapshot() return &explainability.GridSnapshot{ - Width: width, - Depth: depth, - CellSize: cellSize, - OriginX: ox, - OriginZ: oz, - Data: data, - Rows: ny, - Cols: nx, + Width: width, + Depth: depth, + CellSize: cellSize, + OriginX: ox, + OriginZ: oz, + Data: data, + Rows: ny, + Cols: nx, } } diff --git a/mothership/internal/fusion/grid3d.go b/mothership/internal/fusion/grid3d.go index 468a877..9b34bc5 100644 --- a/mothership/internal/fusion/grid3d.go +++ b/mothership/internal/fusion/grid3d.go @@ -11,8 +11,8 @@ const defaultCellSize = 0.2 // metres // Grid3D is a 3D voxel grid for accumulating link activation weights. // Axes: X (width), Y (height), Z (depth). type Grid3D struct { - mu sync.RWMutex - cells []float64 + mu sync.RWMutex + cells []float64 nx, ny, nz int cellSize float64 ox, oy, oz float64 // origin (min corner) diff --git a/mothership/internal/guidedtroubleshoot/discovery.go b/mothership/internal/guidedtroubleshoot/discovery.go index bcef1b5..268bb12 100644 --- a/mothership/internal/guidedtroubleshoot/discovery.go +++ b/mothership/internal/guidedtroubleshoot/discovery.go @@ -9,7 +9,7 @@ import ( // DiscoveryTracker tracks which features have been discovered by the user. // It provides first-run contextual help tooltips that are shown once per feature. type DiscoveryTracker struct { - mu sync.RWMutex + mu sync.RWMutex discovered map[string]discoveryState } diff --git a/mothership/internal/guidedtroubleshoot/quality.go b/mothership/internal/guidedtroubleshoot/quality.go index ca71636..b0f5dd3 100644 --- a/mothership/internal/guidedtroubleshoot/quality.go +++ b/mothership/internal/guidedtroubleshoot/quality.go @@ -11,12 +11,12 @@ import ( // Qualifying settings keys that trigger repeated-edit hints var QualifyingSettingsKeys = map[string]bool{ - "delta_rms_threshold": true, - "breathing_sensitivity": true, - "tau_s": true, - "fresnel_decay": true, - "n_subcarriers": true, - "motion_threshold": true, + "delta_rms_threshold": true, + "breathing_sensitivity": true, + "tau_s": true, + "fresnel_decay": true, + "n_subcarriers": true, + "motion_threshold": true, } // EditTracker tracks edits to settings keys for repeated-edit hints. @@ -28,11 +28,11 @@ type EditTracker struct { // editState tracks the edit count and last edit time for a settings key. type editState struct { - count int - lastEdit time.Time - firstEdit time.Time - hintShown bool - hintReset time.Time // When to allow showing hint again (24h cooldown) + count int + lastEdit time.Time + firstEdit time.Time + hintShown bool + hintReset time.Time // When to allow showing hint again (24h cooldown) } // NewEditTracker creates a new edit tracker. @@ -148,17 +148,17 @@ type ZoneInfo struct { // zoneQualityState tracks the quality state for a single zone. type zoneQualityState struct { - zoneID int - quality float64 - firstPoorTime time.Time // When quality first dropped below 60% - lastPoorTime time.Time - bannerShown bool - resolvedCount int - hysteresis float64 // For quality improvements + zoneID int + quality float64 + firstPoorTime time.Time // When quality first dropped below 60% + lastPoorTime time.Time + bannerShown bool + resolvedCount int + hysteresis float64 // For quality improvements } const ( - QualityThreshold = 60.0 // Quality below this triggers issues + QualityThreshold = 60.0 // Quality below this triggers issues QualityRecovery = 70.0 // Quality above this marks recovery PoorQualityDuration = 24 * time.Hour ) @@ -184,8 +184,8 @@ func (t *ZoneQualityTracker) UpdateQuality(zoneID int, quality float64, timestam state := t.zones[zoneID] if state == nil { state = &zoneQualityState{ - zoneID: zoneID, - quality: quality, + zoneID: zoneID, + quality: quality, hysteresis: quality, } // If initial quality is already poor, set firstPoorTime @@ -276,25 +276,25 @@ func (t *ZoneQualityTracker) Reset() { // Manager coordinates all guided troubleshooting features. type Manager struct { - editTracker *EditTracker - qualityTracker *ZoneQualityTracker - discoveryTracker *DiscoveryTracker - fleetNotifier *FleetNotifier - mu sync.RWMutex - running bool - ctx context.Context - cancel context.CancelFunc - checkInterval time.Duration - onQualityIssue func(zoneID int, quality float64) - onNodeOffline func(mac string, offlineDuration time.Duration) + editTracker *EditTracker + qualityTracker *ZoneQualityTracker + discoveryTracker *DiscoveryTracker + fleetNotifier *FleetNotifier + mu sync.RWMutex + running bool + ctx context.Context + cancel context.CancelFunc + checkInterval time.Duration + onQualityIssue func(zoneID int, quality float64) + onNodeOffline func(mac string, offlineDuration time.Duration) onCalibrationComplete func(zoneID int, qualityBefore, qualityAfter float64) } // ManagerConfig holds configuration for the guided troubleshooting manager. type ManagerConfig struct { - CheckInterval time.Duration // How often to check quality issues - GetAllZones func() ([]ZoneInfo, error) - GetNodeLastSeen func(mac string) time.Time + CheckInterval time.Duration // How often to check quality issues + GetAllZones func() ([]ZoneInfo, error) + GetNodeLastSeen func(mac string) time.Time } // NewManager creates a new guided troubleshooting manager. diff --git a/mothership/internal/guidedtroubleshoot/quality_test.go b/mothership/internal/guidedtroubleshoot/quality_test.go index 81c2af0..127720f 100644 --- a/mothership/internal/guidedtroubleshoot/quality_test.go +++ b/mothership/internal/guidedtroubleshoot/quality_test.go @@ -11,11 +11,11 @@ func TestEditTracker_RecordEdit(t *testing.T) { tracker := NewEditTracker() tests := []struct { - name string - key string - edits int - wantHint bool - description string + name string + key string + edits int + wantHint bool + description string }{ { name: "non-qualifying key", diff --git a/mothership/internal/health/health.go b/mothership/internal/health/health.go index 751b6b8..e8909c3 100644 --- a/mothership/internal/health/health.go +++ b/mothership/internal/health/health.go @@ -14,14 +14,14 @@ import ( // Checker provides health check functionality for the mothership. type Checker struct { - mu sync.RWMutex - startTime time.Time - db *sql.DB - getNodeCount func() int - shedder *loadshed.Shedder - getShedLevel func() int // optional override for load_level - level3Since time.Time // When level 3 shedding started - checkDB func() string // injectable for testing; defaults to defaultCheckDB + mu sync.RWMutex + startTime time.Time + db *sql.DB + getNodeCount func() int + shedder *loadshed.Shedder + getShedLevel func() int // optional override for load_level + level3Since time.Time // When level 3 shedding started + checkDB func() string // injectable for testing; defaults to defaultCheckDB } // Config holds configuration for the health checker. @@ -47,13 +47,13 @@ func New(cfg Config) *Checker { // Response is the health check response JSON structure. type Response struct { - Status string `json:"status"` // "ok" or "degraded" - UptimeS int64 `json:"uptime_s"` // seconds since start - Version string `json:"version"` // mothership version - NodesOnline int `json:"nodes_online"` // count of connected nodes - DB string `json:"db"` // "ok" or "failing" - SheddingLevel int `json:"shedding_level"` // 0-3, current load shedding level - Reason string `json:"reason,omitempty"` // explanation of degradation (only when status=degraded) + Status string `json:"status"` // "ok" or "degraded" + UptimeS int64 `json:"uptime_s"` // seconds since start + Version string `json:"version"` // mothership version + NodesOnline int `json:"nodes_online"` // count of connected nodes + DB string `json:"db"` // "ok" or "failing" + SheddingLevel int `json:"shedding_level"` // 0-3, current load shedding level + Reason string `json:"reason,omitempty"` // explanation of degradation (only when status=degraded) } // Handler returns an http.HandlerFunc that performs the health check. diff --git a/mothership/internal/health/health_test.go b/mothership/internal/health/health_test.go index 6ba7456..5e572cd 100644 --- a/mothership/internal/health/health_test.go +++ b/mothership/internal/health/health_test.go @@ -14,8 +14,8 @@ import ( // TestHealthCheckOK tests that health check returns OK when all components are healthy. func TestHealthCheckOK(t *testing.T) { checker := &Checker{ - startTime: time.Now(), - db: &sql.DB{}, // Mock - we'll override checkDB for testing + startTime: time.Now(), + db: &sql.DB{}, // Mock - we'll override checkDB for testing getNodeCount: func() int { return 3 }, shedder: loadshed.New(), } @@ -53,10 +53,10 @@ func TestHealthCheckOK(t *testing.T) { // TestHealthCheckDBFailing tests that health check returns degraded when DB fails. func TestHealthCheckDBFailing(t *testing.T) { checker := &Checker{ - startTime: time.Now(), - db: nil, // No DB = failing + startTime: time.Now(), + db: nil, // No DB = failing getNodeCount: func() int { return 3 }, - shedder: loadshed.New(), + shedder: loadshed.New(), } resp := checker.check("1.0.0") @@ -76,10 +76,10 @@ func TestHealthCheckDBFailing(t *testing.T) { // (zero nodes is valid for headless deployments). func TestHealthCheckNoNodes(t *testing.T) { checker := &Checker{ - startTime: time.Now().Add(-6 * time.Minute), // 6 minutes ago - db: &sql.DB{}, + startTime: time.Now().Add(-6 * time.Minute), // 6 minutes ago + db: &sql.DB{}, getNodeCount: func() int { return 0 }, - shedder: loadshed.New(), + shedder: loadshed.New(), } // Override checkDB to return OK @@ -100,10 +100,10 @@ func TestHealthCheckNoNodes(t *testing.T) { // TestHealthCheckNoNodesWithinGracePeriod tests that health check is OK within 5 min grace period. func TestHealthCheckNoNodesWithinGracePeriod(t *testing.T) { checker := &Checker{ - startTime: time.Now().Add(-2 * time.Minute), // 2 minutes ago - db: &sql.DB{}, + startTime: time.Now().Add(-2 * time.Minute), // 2 minutes ago + db: &sql.DB{}, getNodeCount: func() int { return 0 }, - shedder: loadshed.New(), + shedder: loadshed.New(), } // Override checkDB to return OK @@ -152,11 +152,11 @@ func TestHealthCheckLoadLevel3(t *testing.T) { // JSON response and reflects the shedder's current level. func TestHealthCheckSheddingLevelJSON(t *testing.T) { tests := []struct { - name string - shedLevel loadshed.Level - wantLevel int - wantStatus string - wantDegraded bool + name string + shedLevel loadshed.Level + wantLevel int + wantStatus string + wantDegraded bool }{ {"normal", loadshed.LevelNormal, 0, "ok", false}, {"light", loadshed.LevelLight, 1, "ok", false}, @@ -198,7 +198,7 @@ func TestHealthCheckSheddingLevelJSON(t *testing.T) { // TestHealthCheckHandler tests the HTTP handler returns correct status codes. func TestHealthCheckHandler(t *testing.T) { checker := New(Config{ - DB: &sql.DB{}, + DB: &sql.DB{}, GetNodeCount: func() int { return 2 }, Shedder: loadshed.New(), }) @@ -227,7 +227,7 @@ func TestHealthCheckHandler(t *testing.T) { // TestHealthCheckHandlerDegraded tests the HTTP handler returns 503 for degraded state. func TestHealthCheckHandlerDegraded(t *testing.T) { checker := New(Config{ - DB: nil, // Failing DB + DB: nil, // Failing DB GetNodeCount: func() int { return 2 }, Shedder: loadshed.New(), }) @@ -257,10 +257,10 @@ func TestHealthCheckUptimeIncrement(t *testing.T) { // Backdate startTime so the first check yields at least 1s uptime, // then wait past the next whole second boundary for a measurable increment. checker := &Checker{ - startTime: time.Now().Add(-1900 * time.Millisecond), - db: &sql.DB{}, + startTime: time.Now().Add(-1900 * time.Millisecond), + db: &sql.DB{}, getNodeCount: func() int { return 1 }, - shedder: loadshed.New(), + shedder: loadshed.New(), } checker.checkDB = func() string { return "ok" } diff --git a/mothership/internal/help/monitor.go b/mothership/internal/help/monitor.go index abebb09..086737b 100644 --- a/mothership/internal/help/monitor.go +++ b/mothership/internal/help/monitor.go @@ -13,12 +13,12 @@ import ( // FeatureMonitor checks for feature availability and fires notifications. // It runs periodically to check if features have become available. type FeatureMonitor struct { - mu sync.Mutex - db *sql.DB - notifier *Notifier - checkInterval time.Duration - stopCh chan struct{} - wg sync.WaitGroup + mu sync.Mutex + db *sql.DB + notifier *Notifier + checkInterval time.Duration + stopCh chan struct{} + wg sync.WaitGroup // Callbacks for checking feature availability checkDiurnalReady func() bool @@ -49,10 +49,10 @@ func NewFeatureMonitor(cfg FeatureMonitorConfig) *FeatureMonitor { } return &FeatureMonitor{ - db: cfg.DB, - notifier: cfg.Notifier, - checkInterval: cfg.CheckInterval, - stopCh: make(chan struct{}), + db: cfg.DB, + notifier: cfg.Notifier, + checkInterval: cfg.CheckInterval, + stopCh: make(chan struct{}), notifiedPredictionReady: make(map[string]bool), } } diff --git a/mothership/internal/help/notifier.go b/mothership/internal/help/notifier.go index 94aeb33..4fc031e 100644 --- a/mothership/internal/help/notifier.go +++ b/mothership/internal/help/notifier.go @@ -18,39 +18,39 @@ import ( // Notifier manages feature discovery notifications. type Notifier struct { - mu sync.RWMutex - db *sql.DB + mu sync.RWMutex + db *sql.DB quietHours *QuietHours } // QuietHours defines when notifications should be suppressed. type QuietHours struct { - Enabled bool - StartHour int // 0-23 - StartMin int - EndHour int - EndMin int - DaysMask int // Bitmask for days (0=Sun, 1=Mon, ..., 6=Sat) + Enabled bool + StartHour int // 0-23 + StartMin int + EndHour int + EndMin int + DaysMask int // Bitmask for days (0=Sun, 1=Mon, ..., 6=Sat) } // FeatureNotification represents a one-time notification for a feature. type FeatureNotification struct { - EventID string `json:"event_id"` // Unique identifier - Title string `json:"title"` - Message string `json:"message"` - ActionLabel string `json:"action_label,omitempty"` // Button text - ActionURL string `json:"action_url,omitempty"` // Link for button - DismissedAt *time.Time `json:"dismissed_at,omitempty"` - FiredAt time.Time `json:"fired_at"` + EventID string `json:"event_id"` // Unique identifier + Title string `json:"title"` + Message string `json:"message"` + ActionLabel string `json:"action_label,omitempty"` // Button text + ActionURL string `json:"action_url,omitempty"` // Link for button + DismissedAt *time.Time `json:"dismissed_at,omitempty"` + FiredAt time.Time `json:"fired_at"` } // Predefined feature notification events const ( - EventDiurnalBaselineActivated = "diurnal_baseline_activated" - EventFirstSleepSessionComplete = "first_sleep_session_complete" - EventWeightUpdateApproved = "weight_update_approved" - EventAutomationFirstFired = "automation_first_fired" - EventPredictionModelReady = "prediction_model_ready" + EventDiurnalBaselineActivated = "diurnal_baseline_activated" + EventFirstSleepSessionComplete = "first_sleep_session_complete" + EventWeightUpdateApproved = "weight_update_approved" + EventAutomationFirstFired = "automation_first_fired" + EventPredictionModelReady = "prediction_model_ready" ) // NewNotifier creates a new feature notification manager. @@ -149,7 +149,7 @@ func (n *Notifier) isQuietHours(t time.Time) bool { // Check day of week dow := int(t.Weekday()) - if n.quietHours.DaysMask != 0 && (n.quietHours.DaysMask & (1 << dow)) == 0 { + if n.quietHours.DaysMask != 0 && (n.quietHours.DaysMask&(1<= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || - containsInMiddle(s, substr))) + containsInMiddle(s, substr))) } func containsInMiddle(s, substr string) bool { diff --git a/mothership/internal/ingestion/message.go b/mothership/internal/ingestion/message.go index 2db9101..7094029 100644 --- a/mothership/internal/ingestion/message.go +++ b/mothership/internal/ingestion/message.go @@ -24,27 +24,27 @@ type HelloMessage struct { // HealthMessage is sent every 10 seconds type HealthMessage struct { - Type string `json:"type"` - MAC string `json:"mac"` - TimestampMS int64 `json:"timestamp_ms"` - FreeHeapBytes int64 `json:"free_heap_bytes"` - WifiRSSIdBm int `json:"wifi_rssi_dbm"` - UptimeMS int64 `json:"uptime_ms"` - TemperatureC float64 `json:"temperature_c,omitempty"` - CSIRateHz int `json:"csi_rate_hz"` - WifiChannel int `json:"wifi_channel"` - IP string `json:"ip,omitempty"` - NTPSynced bool `json:"ntp_synced"` + Type string `json:"type"` + MAC string `json:"mac"` + TimestampMS int64 `json:"timestamp_ms"` + FreeHeapBytes int64 `json:"free_heap_bytes"` + WifiRSSIdBm int `json:"wifi_rssi_dbm"` + UptimeMS int64 `json:"uptime_ms"` + TemperatureC float64 `json:"temperature_c,omitempty"` + CSIRateHz int `json:"csi_rate_hz"` + WifiChannel int `json:"wifi_channel"` + IP string `json:"ip,omitempty"` + NTPSynced bool `json:"ntp_synced"` } // BLEDevice represents a discovered BLE device type BLEDevice struct { - Addr string `json:"addr"` - AddrType string `json:"addr_type,omitempty"` - RSSIdBm int `json:"rssi_dbm"` - Name string `json:"name,omitempty"` - MfrID int `json:"mfr_id,omitempty"` - MfrDataHex string `json:"mfr_data_hex,omitempty"` + Addr string `json:"addr"` + AddrType string `json:"addr_type,omitempty"` + RSSIdBm int `json:"rssi_dbm"` + Name string `json:"name,omitempty"` + MfrID int `json:"mfr_id,omitempty"` + MfrDataHex string `json:"mfr_data_hex,omitempty"` } // BLEMessage is sent every 5 seconds with discovered devices @@ -83,11 +83,11 @@ type RoleMessage struct { // ConfigMessage changes operational parameters type ConfigMessage struct { - Type string `json:"type"` - RateHz *int `json:"rate_hz,omitempty"` - TXSlotUS *int `json:"tx_slot_us,omitempty"` + Type string `json:"type"` + RateHz *int `json:"rate_hz,omitempty"` + TXSlotUS *int `json:"tx_slot_us,omitempty"` VarianceThreshold *float64 `json:"variance_threshold,omitempty"` - NTPServer *string `json:"ntp_server,omitempty"` + NTPServer *string `json:"ntp_server,omitempty"` } // OTAMessage triggers a firmware update @@ -100,8 +100,8 @@ type OTAMessage struct { // RebootMessage triggers a node reboot type RebootMessage struct { - Type string `json:"type"` - DelayMS int `json:"delay_ms,omitempty"` + Type string `json:"type"` + DelayMS int `json:"delay_ms,omitempty"` } // IdentifyMessage triggers LED blink for identification diff --git a/mothership/internal/ingestion/ratecontrol_test.go b/mothership/internal/ingestion/ratecontrol_test.go index e07adbd..d7264dc 100644 --- a/mothership/internal/ingestion/ratecontrol_test.go +++ b/mothership/internal/ingestion/ratecontrol_test.go @@ -6,8 +6,8 @@ import ( ) type rateSend struct { - mac string - rate int + mac string + rate int varianceThreshold float64 } diff --git a/mothership/internal/ingestion/server.go b/mothership/internal/ingestion/server.go index aedb8c8..a0fdc02 100644 --- a/mothership/internal/ingestion/server.go +++ b/mothership/internal/ingestion/server.go @@ -69,9 +69,9 @@ type MotionStateItem struct { DiurnalConfidence float64 `json:"diurnal_confidence"` DiurnalReady bool `json:"diurnal_ready"` // Breathing detection fields (Phase 6) - BreathingState string `json:"breathing_state"` // CLEAR, POSSIBLY_PRESENT, MOTION_DETECTED, STATIONARY_DETECTED + BreathingState string `json:"breathing_state"` // CLEAR, POSSIBLY_PRESENT, MOTION_DETECTED, STATIONARY_DETECTED BreathingFreqHz *float64 `json:"breathing_freq_hz,omitempty"` // Breathing frequency in Hz, null if not detected - BreathingBPM float64 `json:"breathing_bpm"` // Breathing rate in breaths per minute + BreathingBPM float64 `json:"breathing_bpm"` // Breathing rate in breaths per minute } // ReplayAppender appends raw CSI frames to a persistent store. @@ -109,7 +109,7 @@ type Server struct { // Optional pipeline components (set via setters) dashboardBroadcaster CSIBroadcaster motionBroadcaster MotionBroadcaster - eventBroadcaster EventBroadcaster + eventBroadcaster EventBroadcaster processorMgr *signal.ProcessorManager replayStore ReplayAppender recorder Recorder @@ -971,13 +971,13 @@ func (s *Server) GetUnpairedMACs() []string { // LinkInfo represents a link with its endpoints and health data type LinkInfo struct { - ID string `json:"id"` - NodeMAC string `json:"node_mac"` - PeerMAC string `json:"peer_mac"` + ID string `json:"id"` + NodeMAC string `json:"node_mac"` + PeerMAC string `json:"peer_mac"` HealthScore float64 `json:"health_score,omitempty"` // Ambient confidence score (0-1) // Alias fields for frontend compatibility with proactive.js CompositeScore float64 `json:"composite_score,omitempty"` // Alias for HealthScore - Quality float64 `json:"quality,omitempty"` // Alias for HealthScore + Quality float64 `json:"quality,omitempty"` // Alias for HealthScore LinkID string `json:"link_id,omitempty"` // Alias for ID } @@ -1111,10 +1111,10 @@ func (s *Server) GetAllLinksWithHealth() []LinkHealthInfo { if info.HealthScore == 0 && info.HealthDetails == (signal.HealthDetails{}) { info.HealthScore = 0.5 info.HealthDetails = signal.HealthDetails{ - SNR: 0.5, + SNR: 0.5, PhaseStability: 0.5, - PacketRate: 0.5, - BaselineDrift: 0.5, + PacketRate: 0.5, + BaselineDrift: 0.5, } } @@ -1153,10 +1153,10 @@ func (s *Server) GetLinkWithHealth(linkID string) *LinkHealthInfo { if info.HealthScore == 0 && info.HealthDetails == (signal.HealthDetails{}) { info.HealthScore = 0.5 info.HealthDetails = signal.HealthDetails{ - SNR: 0.5, + SNR: 0.5, PhaseStability: 0.5, - PacketRate: 0.5, - BaselineDrift: 0.5, + PacketRate: 0.5, + BaselineDrift: 0.5, } } diff --git a/mothership/internal/ingestion/server_test.go b/mothership/internal/ingestion/server_test.go index b6aae2b..4281f62 100644 --- a/mothership/internal/ingestion/server_test.go +++ b/mothership/internal/ingestion/server_test.go @@ -422,28 +422,28 @@ func TestTokenValidation_NoValidator(t *testing.T) { func TestMigrationWindow(t *testing.T) { tests := []struct { - name string - validator func(mac, token string) bool + name string + validator func(mac, token string) bool migrationDeadline time.Time - helloJSON string - wantAccepted bool - wantUnpaired bool + helloJSON string + wantAccepted bool + wantUnpaired bool }{ { - name: "no validator accepts normally", - validator: nil, + name: "no validator accepts normally", + validator: nil, migrationDeadline: time.Time{}, - helloJSON: `{"type":"hello","mac":"AA:BB:CC:DD:EE:FF","firmware_version":"1.0.0","chip":"ESP32-S3"}`, - wantAccepted: true, - wantUnpaired: false, + helloJSON: `{"type":"hello","mac":"AA:BB:CC:DD:EE:FF","firmware_version":"1.0.0","chip":"ESP32-S3"}`, + wantAccepted: true, + wantUnpaired: false, }, { - name: "valid token always accepted", - validator: func(mac, token string) bool { return token == "good" }, + name: "valid token always accepted", + validator: func(mac, token string) bool { return token == "good" }, migrationDeadline: time.Now().Add(24 * time.Hour), - helloJSON: `{"type":"hello","mac":"AA:BB:CC:DD:EE:FF","firmware_version":"1.0.0","chip":"ESP32-S3","token":"good"}`, - wantAccepted: true, - wantUnpaired: false, + helloJSON: `{"type":"hello","mac":"AA:BB:CC:DD:EE:FF","firmware_version":"1.0.0","chip":"ESP32-S3","token":"good"}`, + wantAccepted: true, + wantUnpaired: false, }, { name: "missing token accepted during migration window", diff --git a/mothership/internal/learning/accuracy.go b/mothership/internal/learning/accuracy.go index 23c49c3..bb45418 100644 --- a/mothership/internal/learning/accuracy.go +++ b/mothership/internal/learning/accuracy.go @@ -338,11 +338,11 @@ func (a *AccuracyComputer) GetImprovementStats() (map[string]interface{}, error) } return map[string]interface{}{ - "current_f1": currentAvg, - "last_week_f1": lastAvg, - "improvement_pct": improvement, - "total_feedback": stats["total_count"], + "current_f1": currentAvg, + "last_week_f1": lastAvg, + "improvement_pct": improvement, + "total_feedback": stats["total_count"], "this_week_feedback": stats["this_week_count"], - "unprocessed_count": stats["unprocessed_count"], + "unprocessed_count": stats["unprocessed_count"], }, nil } diff --git a/mothership/internal/learning/feedback_store.go b/mothership/internal/learning/feedback_store.go index b533780..667635a 100644 --- a/mothership/internal/learning/feedback_store.go +++ b/mothership/internal/learning/feedback_store.go @@ -18,21 +18,21 @@ import ( type FeedbackType string const ( - TruePositive FeedbackType = "TRUE_POSITIVE" - FalsePositive FeedbackType = "FALSE_POSITIVE" - FalseNegative FeedbackType = "FALSE_NEGATIVE" - WrongIdentity FeedbackType = "WRONG_IDENTITY" - WrongZone FeedbackType = "WRONG_ZONE" + TruePositive FeedbackType = "TRUE_POSITIVE" + FalsePositive FeedbackType = "FALSE_POSITIVE" + FalseNegative FeedbackType = "FALSE_NEGATIVE" + WrongIdentity FeedbackType = "WRONG_IDENTITY" + WrongZone FeedbackType = "WRONG_ZONE" ) // EventType represents the type of detection event type EventType string const ( - BlobDetection EventType = "blob_detection" - ZoneTransition EventType = "zone_transition" - FallAlert EventType = "fall_alert" - Anomaly EventType = "anomaly" + BlobDetection EventType = "blob_detection" + ZoneTransition EventType = "zone_transition" + FallAlert EventType = "fall_alert" + Anomaly EventType = "anomaly" ) // FeedbackRecord represents a single feedback entry @@ -49,20 +49,20 @@ type FeedbackRecord struct { // FalsePositiveFrame represents CSI data for a known false positive type FalsePositiveFrame struct { - LinkID string `json:"link_id"` - Timestamp time.Time `json:"timestamp"` - DeltaRMS float64 `json:"delta_rms"` - Context map[string]interface{} `json:"context"` + LinkID string `json:"link_id"` + Timestamp time.Time `json:"timestamp"` + DeltaRMS float64 `json:"delta_rms"` + Context map[string]interface{} `json:"context"` } // FalseNegativeFrame represents CSI data for a known false negative type FalseNegativeFrame struct { - LinkID string `json:"link_id"` - Timestamp time.Time `json:"timestamp"` - ExpectedPositionX float64 `json:"expected_position_x"` - ExpectedPositionY float64 `json:"expected_position_y"` - ExpectedPositionZ float64 `json:"expected_position_z"` - Context map[string]interface{} `json:"context"` + LinkID string `json:"link_id"` + Timestamp time.Time `json:"timestamp"` + ExpectedPositionX float64 `json:"expected_position_x"` + ExpectedPositionY float64 `json:"expected_position_y"` + ExpectedPositionZ float64 `json:"expected_position_z"` + Context map[string]interface{} `json:"context"` } // FeedbackStore persists detection feedback to SQLite @@ -384,15 +384,15 @@ func (s *FeedbackStore) GetFalseNegativeFrames(linkID string, window time.Durati // AccuracyRecord represents weekly accuracy metrics for a scope type AccuracyRecord struct { - Week string `json:"week"` - ScopeType string `json:"scope_type"` - ScopeID string `json:"scope_id"` - Precision float64 `json:"precision"` - Recall float64 `json:"recall"` - F1 float64 `json:"f1"` - TPCount int `json:"tp_count"` - FPCount int `json:"fp_count"` - FNCount int `json:"fn_count"` + Week string `json:"week"` + ScopeType string `json:"scope_type"` + ScopeID string `json:"scope_id"` + Precision float64 `json:"precision"` + Recall float64 `json:"recall"` + F1 float64 `json:"f1"` + TPCount int `json:"tp_count"` + FPCount int `json:"fp_count"` + FNCount int `json:"fn_count"` ComputedAt time.Time `json:"computed_at"` } diff --git a/mothership/internal/learning/feedback_test.go b/mothership/internal/learning/feedback_test.go index e08a8fd..d5cc57a 100644 --- a/mothership/internal/learning/feedback_test.go +++ b/mothership/internal/learning/feedback_test.go @@ -137,9 +137,9 @@ func TestFalsePositiveFrameStorage(t *testing.T) { // Add false positive frame frame := FalsePositiveFrame{ - LinkID: "link-1-2", + LinkID: "link-1-2", Timestamp: time.Now(), - DeltaRMS: 0.15, + DeltaRMS: 0.15, Context: map[string]interface{}{ "zone_id": "zone-living", }, diff --git a/mothership/internal/learning/handler.go b/mothership/internal/learning/handler.go index 4648ee8..06a47a6 100644 --- a/mothership/internal/learning/handler.go +++ b/mothership/internal/learning/handler.go @@ -28,10 +28,10 @@ type PositionAccuracyProvider interface { // Handler provides REST API handlers for the learning package type Handler struct { - store *FeedbackStore - processor *Processor - accuracyComp *AccuracyComputer - spatialWeightProv SpatialWeightProvider + store *FeedbackStore + processor *Processor + accuracyComp *AccuracyComputer + spatialWeightProv SpatialWeightProvider positionAccuracyProv PositionAccuracyProvider } @@ -96,11 +96,11 @@ func (h *Handler) handleSubmitFeedback(w http.ResponseWriter, r *http.Request) { // Validate feedback type validTypes := map[string]bool{ - string(TruePositive): true, - string(FalsePositive): true, - string(FalseNegative): true, - string(WrongIdentity): true, - string(WrongZone): true, + string(TruePositive): true, + string(FalsePositive): true, + string(FalseNegative): true, + string(WrongIdentity): true, + string(WrongZone): true, } if !validTypes[req.FeedbackType] { http.Error(w, "invalid feedback_type", http.StatusBadRequest) @@ -109,10 +109,10 @@ func (h *Handler) handleSubmitFeedback(w http.ResponseWriter, r *http.Request) { // Validate event type validEventTypes := map[string]bool{ - string(BlobDetection): true, - string(ZoneTransition): true, - string(FallAlert): true, - string(Anomaly): true, + string(BlobDetection): true, + string(ZoneTransition): true, + string(FallAlert): true, + string(Anomaly): true, } if !validEventTypes[req.EventType] { http.Error(w, "invalid event_type", http.StatusBadRequest) diff --git a/mothership/internal/loadshed/loadshed.go b/mothership/internal/loadshed/loadshed.go index 75f0528..1ef0ce6 100644 --- a/mothership/internal/loadshed/loadshed.go +++ b/mothership/internal/loadshed/loadshed.go @@ -6,7 +6,8 @@ // Level 1 (light): rolling avg >= 80 ms — suspend crowd flow accumulation // Level 2 (moderate): rolling avg >= 90 ms — also suspend CSI replay buffer writes // Level 3 (heavy): rolling avg >= 95 ms — drop CSI frames when ingest channel > 50% full; -// push rate reduction config to all nodes (10 Hz cap) +// +// push rate reduction config to all nodes (10 Hz cap) // // Recovery: when rolling avg < 60 ms for 10 consecutive iterations, step down one level. // @@ -94,8 +95,8 @@ type Shedder struct { recoveryTicks atomic.Int32 // Consecutive iterations below recovery threshold. // Rolling average window (ring buffer). - durations [rollingWindowSize]time.Duration - durationsIdx int + durations [rollingWindowSize]time.Duration + durationsIdx int durationsFilled int // how many slots have been written (< rollingWindowSize on startup) // Pipeline stage timing for instrumentation (captured at EndIteration). @@ -114,7 +115,7 @@ type Shedder struct { ratePush RatePushCallback // Previous rate before Level 3 was entered, for restoration. - prevRateHz atomic.Int32 + prevRateHz atomic.Int32 level3Active atomic.Bool // OnLevelChange is an optional callback invoked after a level change. diff --git a/mothership/internal/loadshed/loadshed_test.go b/mothership/internal/loadshed/loadshed_test.go index faae5b2..1cedcb8 100644 --- a/mothership/internal/loadshed/loadshed_test.go +++ b/mothership/internal/loadshed/loadshed_test.go @@ -720,7 +720,7 @@ func TestSetPreviousRateStoresCorrectly(t *testing.T) { s := New() tests := []struct { - hz int + hz int want int32 }{ {20, 20}, @@ -742,9 +742,9 @@ func TestSetPreviousRateStoresCorrectly(t *testing.T) { // entering L3 pushes 10 Hz, exiting L3 restores the previous rate. func TestLevel3RatePushAndRestore(t *testing.T) { tests := []struct { - name string - prevRate int - wantCap int + name string + prevRate int + wantCap int wantRestore int }{ {"default_20", 20, level3RateCapHz, 20}, @@ -863,11 +863,11 @@ func TestStageTimingOverflow(t *testing.T) { // Level 3 and the channel-fullness callback. func TestShouldDropFramesChannelFullAtL3(t *testing.T) { tests := []struct { - name string - level Level + name string + level Level channelFull bool hasCallback bool - want bool + want bool }{ {"L3 full with callback", LevelHeavy, true, true, true}, {"L3 not full with callback", LevelHeavy, false, true, false}, diff --git a/mothership/internal/localization/fusion.go b/mothership/internal/localization/fusion.go index 5f757c9..00ecd6b 100644 --- a/mothership/internal/localization/fusion.go +++ b/mothership/internal/localization/fusion.go @@ -24,10 +24,10 @@ type NodePosition struct { // FusionResult is returned after each fusion cycle. type FusionResult struct { - Peaks [][3]float64 // [x, z, weight] top peaks - GridCols int - GridRows int - GridData []float64 // normalised [0..1] row-major + Peaks [][3]float64 // [x, z, weight] top peaks + GridCols int + GridRows int + GridData []float64 // normalised [0..1] row-major Timestamp time.Time } diff --git a/mothership/internal/localization/grid.go b/mothership/internal/localization/grid.go index 40f50ef..9b3e230 100644 --- a/mothership/internal/localization/grid.go +++ b/mothership/internal/localization/grid.go @@ -112,7 +112,7 @@ func (g *Grid) AddLinkInfluenceWithSigma(ax, az, bx, bz, weight, sigmaMultiplier if excess < 0 { excess = 0 } - influence := weight * math.Exp(-(excess * excess) / twoSigSq) + influence := weight * math.Exp(-(excess*excess)/twoSigSq) g.cells[row*g.cols+col] += influence } } @@ -173,7 +173,7 @@ func (g *Grid) AddLinkInfluenceWithSpatialWeights(ax, az, bx, bz, weight, sigmaM cellWeight = weight * spatialWeightFunc(px, pz) } - influence := cellWeight * math.Exp(-(excess * excess) / twoSigSq) + influence := cellWeight * math.Exp(-(excess*excess)/twoSigSq) g.cells[row*g.cols+col] += influence } } diff --git a/mothership/internal/localization/groundtruth.go b/mothership/internal/localization/groundtruth.go index b510dd8..0efecbe 100644 --- a/mothership/internal/localization/groundtruth.go +++ b/mothership/internal/localization/groundtruth.go @@ -27,10 +27,10 @@ var _ GroundTruthSource = (*BLEGroundTruthProvider)(nil) // GroundTruthPosition represents a known position from a ground truth source type GroundTruthPosition struct { EntityID string `json:"entity_id"` - X float64 `json:"x"` // Position X in metres - Y float64 `json:"y"` // Position Y in metres - Z float64 `json:"z"` // Position Z in metres - Accuracy float64 `json:"accuracy"` // Position accuracy in metres (1σ) + X float64 `json:"x"` // Position X in metres + Y float64 `json:"y"` // Position Y in metres + Z float64 `json:"z"` // Position Z in metres + Accuracy float64 `json:"accuracy"` // Position accuracy in metres (1σ) Timestamp time.Time `json:"timestamp"` Source string `json:"source"` // e.g., "ble", "manual" Confidence float64 `json:"confidence"` // Source confidence (0-1) @@ -75,9 +75,9 @@ type RSSIObservation struct { // BLEGroundTruthProvider uses BLE RSSI trilateration to provide ground truth positions type BLEGroundTruthProvider struct { - mu sync.RWMutex - config BLETrilaterationConfig - nodePos map[string]NodePosition // Node MAC -> position + mu sync.RWMutex + config BLETrilaterationConfig + nodePos map[string]NodePosition // Node MAC -> position // RSSI buffer: entityID -> nodeMAC -> observation observations map[string]map[string]*RSSIObservation @@ -229,9 +229,9 @@ func (p *BLEGroundTruthProvider) trilaterateLocked(entityID string, observations // Build distance constraints type constraint struct { - nodePos NodePosition - distance float64 - weight float64 + nodePos NodePosition + distance float64 + weight float64 } var constraints []constraint @@ -283,8 +283,8 @@ func (p *BLEGroundTruthProvider) trilaterateLocked(entityID string, observations for iter := 0; iter < maxIter; iter++ { // Build Jacobian and residual - var jacobian [][3]float64 // n x 3 - var residuals []float64 // n x 1 + var jacobian [][3]float64 // n x 3 + var residuals []float64 // n x 1 var weights []float64 for _, c := range constraints { diff --git a/mothership/internal/localization/groundtruth_store.go b/mothership/internal/localization/groundtruth_store.go index 7bbd710..cce822b 100644 --- a/mothership/internal/localization/groundtruth_store.go +++ b/mothership/internal/localization/groundtruth_store.go @@ -17,9 +17,9 @@ import ( // Collection gates and grid configuration const ( - MinBLEConfidence = 0.7 // Minimum BLE triangulation confidence - MaxBLEBlobDistance = 0.5 // Maximum BLE-blob distance (metres) - ZoneGridCellSize = 0.5 // Zone grid cell size (metres) + MinBLEConfidence = 0.7 // Minimum BLE triangulation confidence + MaxBLEBlobDistance = 0.5 // Maximum BLE-blob distance (metres) + ZoneGridCellSize = 0.5 // Zone grid cell size (metres) ) // GroundTruthSample represents a single ground truth observation @@ -30,9 +30,9 @@ type GroundTruthSample struct { ID int64 `json:"id"` Timestamp time.Time `json:"timestamp"` PersonID string `json:"person_id"` - BLEPosition Vec3 `json:"ble_position"` // Ground truth from BLE - BlobPosition Vec3 `json:"blob_position"` // CSI fusion estimate - PositionError float64 `json:"position_error"` // Distance between BLE and blob + BLEPosition Vec3 `json:"ble_position"` // Ground truth from BLE + BlobPosition Vec3 `json:"blob_position"` // CSI fusion estimate + PositionError float64 `json:"position_error"` // Distance between BLE and blob PerLinkDeltas map[string]float64 `json:"per_link_deltas"` // linkID -> deltaRMS PerLinkHealth map[string]float64 `json:"per_link_health"` // linkID -> health score BLEConfidence float64 `json:"ble_confidence"` @@ -627,11 +627,11 @@ func (s *GroundTruthStore) GetPositionImprovementStats() (map[string]interface{} s.db.QueryRow(`SELECT COUNT(*) FROM ground_truth_samples WHERE timestamp >= ?`, todayStart.Unix()).Scan(&todaySamples) result := map[string]interface{}{ - "current_week": currentWeek, - "improvement_pct": improvement, - "trend": trend, - "total_samples": totalSamples, - "today_samples": todaySamples, + "current_week": currentWeek, + "improvement_pct": improvement, + "trend": trend, + "total_samples": totalSamples, + "today_samples": todaySamples, } if currentErr == nil { diff --git a/mothership/internal/localization/self_improving.go b/mothership/internal/localization/self_improving.go index bd62093..36af483 100644 --- a/mothership/internal/localization/self_improving.go +++ b/mothership/internal/localization/self_improving.go @@ -10,10 +10,10 @@ import ( // SelfImprovingLocalizerConfig holds configuration for the self-improving localizer type SelfImprovingLocalizerConfig struct { - RoomWidth float64 - RoomDepth float64 - OriginX float64 - OriginZ float64 + RoomWidth float64 + RoomDepth float64 + OriginX float64 + OriginZ float64 AdjustmentInterval time.Duration // How often to adjust weights // BLE ground truth configuration @@ -41,21 +41,21 @@ func DefaultSelfImprovingConfig() SelfImprovingLocalizerConfig { // DefaultSelfImprovingLocalizerConfig returns sensible defaults func DefaultSelfImprovingLocalizerConfig() SelfImprovingLocalizerConfig { return SelfImprovingLocalizerConfig{ - RoomWidth: 10.0, - RoomDepth: 10.0, - OriginX: 0.0, - OriginZ: 0.0, - AdjustmentInterval: 10 * time.Second, - BLEConfig: DefaultBLETrilaterationConfig(), - LearningRate: 0.001, - Regularization: 0.01, - MinZoneSamples: 100, - ValidationBatchSize: 50, + RoomWidth: 10.0, + RoomDepth: 10.0, + OriginX: 0.0, + OriginZ: 0.0, + AdjustmentInterval: 10 * time.Second, + BLEConfig: DefaultBLETrilaterationConfig(), + LearningRate: 0.001, + Regularization: 0.01, + MinZoneSamples: 100, + ValidationBatchSize: 50, ImprovementThreshold: 0.05, - MinWeight: 0.1, - MaxWeight: 3.0, - MinBLEConfidence: MinBLEConfidence, - MaxBLEBlobDistance: MaxBLEBlobDistance, + MinWeight: 0.1, + MaxWeight: 3.0, + MinBLEConfidence: MinBLEConfidence, + MaxBLEBlobDistance: MaxBLEBlobDistance, } } @@ -64,21 +64,21 @@ type SelfImprovingLocalizer struct { mu sync.RWMutex // Core components - engine *Engine - weightLearner *WeightLearner - weightStore *WeightStore - spatialWeightLearner *SpatialWeightLearner - groundTruthProvider GroundTruthSource + engine *Engine + weightLearner *WeightLearner + weightStore *WeightStore + spatialWeightLearner *SpatialWeightLearner + groundTruthProvider GroundTruthSource // Configuration config SelfImprovingLocalizerConfig // Runtime state - running bool - stopChan chan struct{} - lastAdjust time.Time - sampleCount int - adjustCount int + running bool + stopChan chan struct{} + lastAdjust time.Time + sampleCount int + adjustCount int // Improvement tracking improvementHistory []ImprovementRecord @@ -122,7 +122,7 @@ func NewSelfImprovingLocalizer(config SelfImprovingLocalizerConfig) *SelfImprovi groundTruthProvider: groundTruthProvider, config: config, stopChan: make(chan struct{}), - improvementHistory: make([]ImprovementRecord, 0), + improvementHistory: make([]ImprovementRecord, 0), } } @@ -410,13 +410,13 @@ func (s *SelfImprovingLocalizer) GetImprovementStats() map[string]interface{} { } return map[string]interface{}{ - "total_samples": s.sampleCount, - "adjustments": s.adjustCount, - "baseline_error_m": latest.BaselineError, - "current_error_m": latest.CurrentError, - "improvement_pct": latest.ImprovementPct, - "trend": trend, - "last_adjustment": latest.Timestamp, + "total_samples": s.sampleCount, + "adjustments": s.adjustCount, + "baseline_error_m": latest.BaselineError, + "current_error_m": latest.CurrentError, + "improvement_pct": latest.ImprovementPct, + "trend": trend, + "last_adjustment": latest.Timestamp, } } diff --git a/mothership/internal/localization/spatial_weights.go b/mothership/internal/localization/spatial_weights.go index a9da0b2..32e4879 100644 --- a/mothership/internal/localization/spatial_weights.go +++ b/mothership/internal/localization/spatial_weights.go @@ -69,13 +69,13 @@ func DefaultSpatialWeightLearnerConfig() SpatialWeightLearnerConfig { // ZoneWeight represents a learned weight for a link in a zone type ZoneWeight struct { - LinkID string `json:"link_id"` - ZoneGridX int `json:"zone_grid_x"` - ZoneGridY int `json:"zone_grid_y"` - Weight float64 `json:"weight"` - SampleCount int `json:"sample_count"` - LastUpdated time.Time `json:"last_updated"` - ValidationImprovement float64 `json:"validation_improvement"` + LinkID string `json:"link_id"` + ZoneGridX int `json:"zone_grid_x"` + ZoneGridY int `json:"zone_grid_y"` + Weight float64 `json:"weight"` + SampleCount int `json:"sample_count"` + LastUpdated time.Time `json:"last_updated"` + ValidationImprovement float64 `json:"validation_improvement"` } // NewSpatialWeightLearner creates a new spatial weight learner @@ -92,10 +92,10 @@ func NewSpatialWeightLearner(dbPath string, config SpatialWeightLearnerConfig) ( db.SetMaxOpenConns(1) learner := &SpatialWeightLearner{ - db: db, - path: dbPath, - config: config, - weightCache: make(map[string]map[int]map[int]float64), + db: db, + path: dbPath, + config: config, + weightCache: make(map[string]map[int]map[int]float64), validationRatio: 0.2, } @@ -634,13 +634,13 @@ func (l *SpatialWeightLearner) GetWeightStats() map[string]interface{} { } return map[string]interface{}{ - "total_weights": totalWeights, - "links_with_weights": linksWithWeights, - "zones_with_weights": len(zoneCounts), - "avg_weight": avgWeight, - "min_weight": minWeight, - "max_weight": maxWeight, - "update_count": l.updateCounter, + "total_weights": totalWeights, + "links_with_weights": linksWithWeights, + "zones_with_weights": len(zoneCounts), + "avg_weight": avgWeight, + "min_weight": minWeight, + "max_weight": maxWeight, + "update_count": l.updateCounter, } } @@ -757,8 +757,8 @@ func (i *SpatialWeightIntegrator) AdjustAllLinkMotions(links []LinkMotion, blobX // GroundTruthCollector collects ground truth samples from BLE and blob data type GroundTruthCollector struct { - store *GroundTruthStore - learner *SpatialWeightLearner + store *GroundTruthStore + learner *SpatialWeightLearner minConfidence float64 maxDistance float64 } diff --git a/mothership/internal/localization/spatial_weights_test.go b/mothership/internal/localization/spatial_weights_test.go index 917bd1a..eb0ecd6 100644 --- a/mothership/internal/localization/spatial_weights_test.go +++ b/mothership/internal/localization/spatial_weights_test.go @@ -9,10 +9,10 @@ import ( func TestShouldCollectSample_Gates(t *testing.T) { tests := []struct { - name string - confidence float64 - bleBlobDist float64 - expectCollect bool + name string + confidence float64 + bleBlobDist float64 + expectCollect bool }{ { name: "high confidence, close position - should collect", @@ -65,9 +65,9 @@ func TestShouldCollectSample_Gates(t *testing.T) { func TestComputeZoneGrid(t *testing.T) { tests := []struct { - x, z float64 - expectX int - expectY int + x, z float64 + expectX int + expectY int }{ {0.0, 0.0, 0, 0}, {0.25, 0.25, 0, 0}, @@ -143,13 +143,13 @@ func TestSpatialWeightLearner_GetSpatialWeight_BilinearInterpolation(t *testing. }{ // At grid points (exact cell positions) {"at origin", 0.0, 0.0, 1.0}, - {"at (0.5, 0)", 0.5, 0.0, 2.0}, // exact cell (1,0) - {"at (0, 0.5)", 0.0, 0.5, 2.0}, // exact cell (0,1) - {"at (0.5, 0.5)", 0.5, 0.5, 3.0}, // exact cell (1,1) + {"at (0.5, 0)", 0.5, 0.0, 2.0}, // exact cell (1,0) + {"at (0, 0.5)", 0.0, 0.5, 2.0}, // exact cell (0,1) + {"at (0.5, 0.5)", 0.5, 0.5, 3.0}, // exact cell (1,1) // Midpoints between grid cells - {"mid x-axis", 0.25, 0.0, 1.5}, // between (0,0)=1 and (1,0)=2 - {"mid z-axis", 0.0, 0.25, 1.5}, // between (0,0)=1 and (0,1)=2 - {"center", 0.25, 0.25, 2.0}, // bilinear center of 1,2,2,3 + {"mid x-axis", 0.25, 0.0, 1.5}, // between (0,0)=1 and (1,0)=2 + {"mid z-axis", 0.0, 0.25, 1.5}, // between (0,0)=1 and (0,1)=2 + {"center", 0.25, 0.25, 2.0}, // bilinear center of 1,2,2,3 } for _, tt := range tests { @@ -422,10 +422,10 @@ func TestValidationChecker_ShouldAcceptUpdate(t *testing.T) { // Add some samples for validation for i := 0; i < 10; i++ { sample := GroundTruthSample{ - Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), - PersonID: "person1", - BLEPosition: Vec3{X: 1.0, Y: 0.0, Z: 1.0}, - BlobPosition: Vec3{X: 1.0 + float64(i)*0.01, Y: 0.0, Z: 1.0}, // Small errors + Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), + PersonID: "person1", + BLEPosition: Vec3{X: 1.0, Y: 0.0, Z: 1.0}, + BlobPosition: Vec3{X: 1.0 + float64(i)*0.01, Y: 0.0, Z: 1.0}, // Small errors PositionError: float64(i) * 0.01, PerLinkDeltas: map[string]float64{"link1": 0.5}, PerLinkHealth: map[string]float64{"link1": 0.9}, diff --git a/mothership/internal/localization/weightlearner.go b/mothership/internal/localization/weightlearner.go index 7feb4ee..c9174d9 100644 --- a/mothership/internal/localization/weightlearner.go +++ b/mothership/internal/localization/weightlearner.go @@ -639,16 +639,16 @@ func (wl *WeightLearner) GetImprovementStats() map[string]interface{} { duration := current.Timestamp.Sub(initial.Timestamp) return map[string]interface{}{ - "samples": len(history), - "improvement_pct": improvement, - "initial_error_m": initial.AvgErrorM, - "current_error_m": current.AvgErrorM, + "samples": len(history), + "improvement_pct": improvement, + "initial_error_m": initial.AvgErrorM, + "current_error_m": current.AvgErrorM, "initial_observations": initial.Observations, "current_observations": current.Observations, - "learning_duration": duration.String(), - "trend": trend, - "first_sample": initial.Timestamp.Format(time.RFC3339), - "last_sample": current.Timestamp.Format(time.RFC3339), + "learning_duration": duration.String(), + "trend": trend, + "first_sample": initial.Timestamp.Format(time.RFC3339), + "last_sample": current.Timestamp.Format(time.RFC3339), } } diff --git a/mothership/internal/localizer/fusion/timing_budget_test.go b/mothership/internal/localizer/fusion/timing_budget_test.go index 69efab2..2b242c2 100644 --- a/mothership/internal/localizer/fusion/timing_budget_test.go +++ b/mothership/internal/localizer/fusion/timing_budget_test.go @@ -3,19 +3,20 @@ // per plan §Quality Gates / Definition of Done (item 9). // // The benchmark runs the full fusion pipeline: -// 1. Phase sanitization → Feature extraction → Fresnel accumulation → Peak extraction → UKF update -// against synthetic CSI data from spaxel-sim output. +// 1. Phase sanitization → Feature extraction → Fresnel accumulation → Peak extraction → UKF update +// against synthetic CSI data from spaxel-sim output. // // Asserts: // - Median fusion iteration < 15 ms over 600 iterations (60 seconds at 10 Hz) // - P99 < 40 ms (hard limit) // // CI integration: -// Add to Argo Workflows CI step after go test ./...: -// go test -bench=BenchmarkFusionLoop -benchtime=60s -count=1 ./internal/localizer/fusion/ // -// Acceptance: Workflow fails if median latency exceeds 30 ms on CI runner -// (2x allowance for slower hardware; 15 ms production target) +// Add to Argo Workflows CI step after go test ./...: +// go test -bench=BenchmarkFusionLoop -benchtime=60s -count=1 ./internal/localizer/fusion/ +// +// Acceptance: Workflow fails if median latency exceeds 30 ms on CI runner +// (2x allowance for slower hardware; 15 ms production target) package fusion import ( @@ -33,15 +34,15 @@ import ( const ( // WiFi physical constants (matching spaxel-sim) - wavelength = 0.123 // meters (2.4 GHz) - halfWavelength = wavelength / 2.0 - nSub = 64 // number of subcarriers for HT20 - headerSize = 24 // CSI frame header size - fusionRate = 10 // Hz (fusion loop rate) - fusionIterations = 600 // Number of iterations for timing (60s at 10Hz) - productionTarget = 15 * time.Millisecond // Production target (per iteration) - ciThreshold = 30 * time.Millisecond // CI threshold (2x allowance) - hardLimit = 40 * time.Millisecond // P99 hard limit + wavelength = 0.123 // meters (2.4 GHz) + halfWavelength = wavelength / 2.0 + nSub = 64 // number of subcarriers for HT20 + headerSize = 24 // CSI frame header size + fusionRate = 10 // Hz (fusion loop rate) + fusionIterations = 600 // Number of iterations for timing (60s at 10Hz) + productionTarget = 15 * time.Millisecond // Production target (per iteration) + ciThreshold = 30 * time.Millisecond // CI threshold (2x allowance) + hardLimit = 40 * time.Millisecond // P99 hard limit ) // CSIFrame represents a synthetic CSI frame matching spaxel-sim output format @@ -80,7 +81,7 @@ type durationSlice []time.Duration func (d durationSlice) Len() int { return len(d) } func (d durationSlice) Less(i, j int) bool { return d[i] < d[j] } -func (d durationSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] } +func (d durationSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] } // BenchmarkFusionLoop benchmarks the full fusion pipeline timing. // It simulates 4 nodes with 2 walkers, running 600 fusion iterations (60s at 10Hz). @@ -97,12 +98,12 @@ func BenchmarkFusionLoop(b *testing.B) { // Create fusion engine engine := fusion.NewEngine(&fusion.Config{ - Width: 10, - Height: 3, - Depth: 10, - CellSize: 0.2, - MinDeltaRMS: 0.01, - MaxBlobs: 6, + Width: 10, + Height: 3, + Depth: 10, + CellSize: 0.2, + MinDeltaRMS: 0.01, + MaxBlobs: 6, BlobThreshold: 0.3, }) @@ -180,10 +181,10 @@ func BenchmarkFusionLoop(b *testing.B) { // Add link motion if motion detected if result.Features != nil && result.Features.MotionDetected { linkMotions = append(linkMotions, fusion.LinkMotion{ - NodeMAC: macToString(frame.NodeMAC), - PeerMAC: macToString(frame.PeerMAC), - DeltaRMS: result.Features.SmoothDeltaRMS, - Motion: true, + NodeMAC: macToString(frame.NodeMAC), + PeerMAC: macToString(frame.PeerMAC), + DeltaRMS: result.Features.SmoothDeltaRMS, + Motion: true, HealthScore: 1.0, // Perfect health for synthetic data }) } @@ -295,7 +296,7 @@ func createWalkers(count int) []*Walker { ID: i, Position: Point{ X: 2 + rng.Float64()*6, // Keep away from edges - Y: 1.7, // Person height + Y: 1.7, // Person height Z: 2 + rng.Float64()*6, }, Velocity: Point{ @@ -431,7 +432,7 @@ func computeCSIForWalkers(tx, rx *VirtualNode, walkers []*Walker) (float64, floa amplitude := math.Pow(10.0, -totalLossDB/20.0) amplitude *= 1000.0 * decay - phase := 2 * math.Pi * (d1+d2) / wavelength + phase := 2 * math.Pi * (d1 + d2) / wavelength totalAmplitude += amplitude totalPhase += phase * decay @@ -522,12 +523,12 @@ func TestTimingBudgetProduction(t *testing.T) { walkers := createWalkers(2) engine := fusion.NewEngine(&fusion.Config{ - Width: 10, - Height: 3, - Depth: 10, - CellSize: 0.2, - MinDeltaRMS: 0.01, - MaxBlobs: 6, + Width: 10, + Height: 3, + Depth: 10, + CellSize: 0.2, + MinDeltaRMS: 0.01, + MaxBlobs: 6, BlobThreshold: 0.3, }) diff --git a/mothership/internal/mqtt/client.go b/mothership/internal/mqtt/client.go index cb0cb81..87a67d6 100644 --- a/mothership/internal/mqtt/client.go +++ b/mothership/internal/mqtt/client.go @@ -28,14 +28,14 @@ type Config struct { // Spaxel-specific MothershipID string // unique ID for this mothership instance - TopicPrefix string // defaults to "spaxel" + TopicPrefix string // defaults to "spaxel" // Connection settings - KeepAlive time.Duration - ConnectTimeout time.Duration - AutoReconnect bool - ReconnectMin time.Duration // minimum reconnect delay (default 5s) - ReconnectMax time.Duration // maximum reconnect delay (default 120s) + KeepAlive time.Duration + ConnectTimeout time.Duration + AutoReconnect bool + ReconnectMin time.Duration // minimum reconnect delay (default 5s) + ReconnectMax time.Duration // maximum reconnect delay (default 120s) } // HomeAssistantDevice represents a device in HA auto-discovery. @@ -54,24 +54,24 @@ type HADiscoveryConfig struct { Device HomeAssistantDevice `json:"device"` StateTopic string `json:"state_topic"` // Device-specific fields - DeviceClass string `json:"device_class,omitempty"` - UnitOfMeasure string `json:"unit_of_measurement,omitempty"` - Icon string `json:"icon,omitempty"` + DeviceClass string `json:"device_class,omitempty"` + UnitOfMeasure string `json:"unit_of_measurement,omitempty"` + Icon string `json:"icon,omitempty"` JSONAttributesTopic string `json:"json_attributes_topic,omitempty"` - CommandTopic string `json:"command_topic,omitempty"` - PayloadOn string `json:"payload_on,omitempty"` - PayloadOff string `json:"payload_off,omitempty"` - ValueTemplate string `json:"value_template,omitempty"` + CommandTopic string `json:"command_topic,omitempty"` + PayloadOn string `json:"payload_on,omitempty"` + PayloadOff string `json:"payload_off,omitempty"` + ValueTemplate string `json:"value_template,omitempty"` } // EntityConfig holds configuration for an HA entity. type EntityConfig struct { - ID string - Name string - Type string // binary_sensor, sensor, device_tracker - DeviceClass string + ID string + Name string + Type string // binary_sensor, sensor, device_tracker + DeviceClass string UnitOfMeasure string - Icon string + Icon string } // EventPublisherer is an interface for publishing HA auto-discovery configs. @@ -87,15 +87,15 @@ type Client struct { client mqtt.Client // State tracking - connected bool - spaxelDevice HomeAssistantDevice + connected bool + spaxelDevice HomeAssistantDevice publishedEntities map[string]bool // entity ID -> published // Callbacks - onConnect func() - onDisconnect func() - onInitialDiscovery func() // Called on MQTT connect to publish initial discovery configs - eventPublisher EventPublisherer // Optional EventPublisher for initial discovery + onConnect func() + onDisconnect func() + onInitialDiscovery func() // Called on MQTT connect to publish initial discovery configs + eventPublisher EventPublisherer // Optional EventPublisher for initial discovery } // NewClient creates a new MQTT client. @@ -342,12 +342,12 @@ func (c *Client) PublishDeviceTracker(personID, personName string) error { stateTopic := fmt.Sprintf("spaxel/person/%s/state", personID) config := HADiscoveryConfig{ - UniqueID: entityID, - Name: personName, - StateTopic: stateTopic, + UniqueID: entityID, + Name: personName, + StateTopic: stateTopic, JSONAttributesTopic: stateTopic, - Device: c.spaxelDevice, - Icon: "mdi:account", + Device: c.spaxelDevice, + Icon: "mdi:account", } payload, _ := json.Marshal(config) @@ -618,13 +618,13 @@ func (c *Client) PublishPersonPresenceDiscovery(personID, personName string) err stateTopic := fmt.Sprintf("%s/person/%s/presence", c.config.TopicPrefix, personID) config := HADiscoveryConfig{ - Name: fmt.Sprintf("%s Presence", personName), - UniqueID: entityID, - StateTopic: stateTopic, - PayloadOn: "home", - PayloadOff: "not_home", + Name: fmt.Sprintf("%s Presence", personName), + UniqueID: entityID, + StateTopic: stateTopic, + PayloadOn: "home", + PayloadOff: "not_home", DeviceClass: "presence", - Device: c.spaxelDevice, + Device: c.spaxelDevice, } payload, _ := json.Marshal(config) @@ -652,13 +652,13 @@ func (c *Client) PublishZoneOccupancyDiscovery(zoneID, zoneName string) error { occupantsTopic := fmt.Sprintf("%s/zone/%s/occupants", c.config.TopicPrefix, zoneID) config := HADiscoveryConfig{ - Name: fmt.Sprintf("%s Occupancy", zoneName), - UniqueID: entityID, - StateTopic: stateTopic, - UnitOfMeasure: "people", + Name: fmt.Sprintf("%s Occupancy", zoneName), + UniqueID: entityID, + StateTopic: stateTopic, + UnitOfMeasure: "people", JSONAttributesTopic: occupantsTopic, - Icon: "mdi:account-multiple", - Device: c.spaxelDevice, + Icon: "mdi:account-multiple", + Device: c.spaxelDevice, } payload, _ := json.Marshal(config) @@ -685,14 +685,14 @@ func (c *Client) PublishZoneBinaryDiscovery(zoneID, zoneName string) error { stateTopic := fmt.Sprintf("%s/zone/%s/occupied", c.config.TopicPrefix, zoneID) config := HADiscoveryConfig{ - Name: fmt.Sprintf("%s Occupied", zoneName), - UniqueID: entityID, - StateTopic: stateTopic, - PayloadOn: "ON", - PayloadOff: "OFF", + Name: fmt.Sprintf("%s Occupied", zoneName), + UniqueID: entityID, + StateTopic: stateTopic, + PayloadOn: "ON", + PayloadOff: "OFF", DeviceClass: "occupancy", - Icon: "mdi:motion-sensor", - Device: c.spaxelDevice, + Icon: "mdi:motion-sensor", + Device: c.spaxelDevice, } payload, _ := json.Marshal(config) @@ -719,13 +719,13 @@ func (c *Client) PublishFallDetectionDiscovery() error { stateTopic := fmt.Sprintf("%s/fall_detected", c.config.TopicPrefix) config := HADiscoveryConfig{ - Name: "Fall Detected", - UniqueID: entityID, - StateTopic: stateTopic, + Name: "Fall Detected", + UniqueID: entityID, + StateTopic: stateTopic, ValueTemplate: "{% if value_json.person is defined %}ON{% else %}OFF{% endif %}", - DeviceClass: "safety", - Icon: "mdi:human-greeting-proximity", - Device: c.spaxelDevice, + DeviceClass: "safety", + Icon: "mdi:human-greeting-proximity", + Device: c.spaxelDevice, } payload, _ := json.Marshal(config) @@ -752,13 +752,13 @@ func (c *Client) PublishSystemHealthDiscovery() error { stateTopic := fmt.Sprintf("%s/system/health", c.config.TopicPrefix) config := HADiscoveryConfig{ - Name: "Detection Quality", - UniqueID: entityID, - StateTopic: stateTopic, + Name: "Detection Quality", + UniqueID: entityID, + StateTopic: stateTopic, UnitOfMeasure: "%", - DeviceClass: "", - Icon: "mdi:gauge", - Device: c.spaxelDevice, + DeviceClass: "", + Icon: "mdi:gauge", + Device: c.spaxelDevice, } payload, _ := json.Marshal(config) @@ -786,13 +786,13 @@ func (c *Client) PublishSystemModeDiscovery() error { commandTopic := fmt.Sprintf("%s/command/system_mode", c.config.TopicPrefix) config := map[string]interface{}{ - "name": fmt.Sprintf("Spaxel System Mode"), - "unique_id": entityID, - "state_topic": stateTopic, + "name": fmt.Sprintf("Spaxel System Mode"), + "unique_id": entityID, + "state_topic": stateTopic, "command_topic": commandTopic, - "options": []string{"home", "away", "sleep"}, - "device": c.spaxelDevice, - "icon": "mdi:home-switch", + "options": []string{"home", "away", "sleep"}, + "device": c.spaxelDevice, + "icon": "mdi:home-switch", } payload, _ := json.Marshal(config) @@ -878,11 +878,11 @@ func (c *Client) PublishSystemHealth(nodeCount, onlineCount int, detectionQualit topic := fmt.Sprintf("%s/system/health", c.config.TopicPrefix) health := map[string]interface{}{ - "node_count": nodeCount, - "online_count": onlineCount, - "detection_quality": detectionQuality, - "mode": mode, - "timestamp": time.Now().Format(time.RFC3339), + "node_count": nodeCount, + "online_count": onlineCount, + "detection_quality": detectionQuality, + "mode": mode, + "timestamp": time.Now().Format(time.RFC3339), } payload, err := json.Marshal(health) diff --git a/mothership/internal/mqtt/client_test.go b/mothership/internal/mqtt/client_test.go index f234a87..db7e6e2 100644 --- a/mothership/internal/mqtt/client_test.go +++ b/mothership/internal/mqtt/client_test.go @@ -22,10 +22,10 @@ func TestNewClient(t *testing.T) { { name: "valid config", config: Config{ - Broker: "tcp://localhost:1883", - MothershipID: "test-mothership", - Username: "testuser", - Password: "testpass", + Broker: "tcp://localhost:1883", + MothershipID: "test-mothership", + Username: "testuser", + Password: "testpass", }, wantErr: false, }, @@ -39,8 +39,8 @@ func TestNewClient(t *testing.T) { { name: "defaults applied", config: Config{ - Broker: "tcp://localhost:1883", - MothershipID: "test", + Broker: "tcp://localhost:1883", + MothershipID: "test", }, wantErr: false, }, @@ -63,9 +63,9 @@ func TestNewClient(t *testing.T) { // TestHomeAssistantDiscoveryConfig tests HA auto-discovery config generation. func TestHomeAssistantDiscoveryConfig(t *testing.T) { cfg := Config{ - Broker: "tcp://localhost:1883", - MothershipID: "test123", - TopicPrefix: "spaxel", + Broker: "tcp://localhost:1883", + MothershipID: "test123", + TopicPrefix: "spaxel", DiscoveryPrefix: "homeassistant", } @@ -76,13 +76,13 @@ func TestHomeAssistantDiscoveryConfig(t *testing.T) { // Test person presence discovery personConfig := HADiscoveryConfig{ - Name: "Alice Presence", - UniqueID: "spaxel_test123_alice_presence", - StateTopic: "spaxel/person/alice/presence", - PayloadOn: "home", - PayloadOff: "not_home", - DeviceClass: "presence", - Device: client.spaxelDevice, + Name: "Alice Presence", + UniqueID: "spaxel_test123_alice_presence", + StateTopic: "spaxel/person/alice/presence", + PayloadOn: "home", + PayloadOff: "not_home", + DeviceClass: "presence", + Device: client.spaxelDevice, } payload, err := json.Marshal(personConfig) @@ -117,9 +117,9 @@ func TestHomeAssistantDiscoveryConfig(t *testing.T) { // TestTopicGeneration tests MQTT topic generation. func TestTopicGeneration(t *testing.T) { cfg := Config{ - Broker: "tcp://localhost:1883", - MothershipID: "spaxel01", - TopicPrefix: "spaxel", + Broker: "tcp://localhost:1883", + MothershipID: "spaxel01", + TopicPrefix: "spaxel", } client, err := NewClient(cfg) @@ -174,13 +174,13 @@ func TestDiscoveryPayloadFormat(t *testing.T) { // Test binary_sensor discovery payload binaryConfig := map[string]interface{}{ - "name": "Alice Presence", - "unique_id": "spaxel_test123_alice_presence", - "state_topic": "spaxel/person/alice/presence", - "payload_on": "home", - "payload_off": "not_home", - "device_class": "presence", - "device": client.spaxelDevice, + "name": "Alice Presence", + "unique_id": "spaxel_test123_alice_presence", + "state_topic": "spaxel/person/alice/presence", + "payload_on": "home", + "payload_off": "not_home", + "device_class": "presence", + "device": client.spaxelDevice, } payload, err := json.Marshal(binaryConfig) diff --git a/mothership/internal/mqtt/publisher.go b/mothership/internal/mqtt/publisher.go index abded72..d0a7b41 100644 --- a/mothership/internal/mqtt/publisher.go +++ b/mothership/internal/mqtt/publisher.go @@ -13,14 +13,14 @@ import ( // EventPublisher subscribes to the internal event bus and publishes events to MQTT. type EventPublisher struct { - mu sync.RWMutex - client *Client - zones map[string]string // zoneID -> zoneName - people map[string]string // personID -> personName - stopped chan struct{} + mu sync.RWMutex + client *Client + zones map[string]string // zoneID -> zoneName + people map[string]string // personID -> personName + stopped chan struct{} // Track person presence across zones - personZones map[string]map[string]bool // personID -> set of zoneIDs they're in + personZones map[string]map[string]bool // personID -> set of zoneIDs they're in zoneOccupants map[string]map[string]bool // zoneID -> set of personIDs in zone // System health ticker @@ -35,13 +35,13 @@ type EventPublisher struct { // NewEventPublisher creates a new MQTT event publisher. func NewEventPublisher(client *Client) *EventPublisher { return &EventPublisher{ - client: client, - zones: make(map[string]string), - people: make(map[string]string), - personZones: make(map[string]map[string]bool), + client: client, + zones: make(map[string]string), + people: make(map[string]string), + personZones: make(map[string]map[string]bool), zoneOccupants: make(map[string]map[string]bool), - stopped: make(chan struct{}), - healthDone: make(chan struct{}), + stopped: make(chan struct{}), + healthDone: make(chan struct{}), } } diff --git a/mothership/internal/notifications/manager.go b/mothership/internal/notifications/manager.go index 8591109..076840c 100644 --- a/mothership/internal/notifications/manager.go +++ b/mothership/internal/notifications/manager.go @@ -68,35 +68,35 @@ type Event struct { // NotificationConfig holds configuration for notification channels. type NotificationConfig struct { Channel string `json:"channel"` - QuietFrom string `json:"quiet_from"` // HH:MM format - QuietTo string `json:"quiet_to"` // HH:MM format + QuietFrom string `json:"quiet_from"` // HH:MM format + QuietTo string `json:"quiet_to"` // HH:MM format QuietDaysBitmask uint8 `json:"quiet_days_bitmask"` // Bitmask (0=Sun, 1=Mon, ..., 6=Sat) MorningDigest bool `json:"morning_digest"` } // NotificationManager manages notification delivery with batching and quiet hours. type NotificationManager struct { - mu sync.RWMutex - db *sql.DB - config *NotificationConfig - batchWindow time.Duration - maxBatchSize int - pendingLow []*Event - pendingMedium []*Event - batchTimer *time.Timer - queuedForDigest []*Event - sendCallback func(Event) - digestSentDate string // YYYY-MM-DD - location *time.Location + mu sync.RWMutex + db *sql.DB + config *NotificationConfig + batchWindow time.Duration + maxBatchSize int + pendingLow []*Event + pendingMedium []*Event + batchTimer *time.Timer + queuedForDigest []*Event + sendCallback func(Event) + digestSentDate string // YYYY-MM-DD + location *time.Location } // Config holds initialization options for NotificationManager. type Config struct { - DBPath string // Path to SQLite database - BatchWindowSec int // Batching window in seconds (default 30) - MaxBatchSize int // Max events before forcing batch (default 5) - Location *time.Location // Timezone for quiet hours (default UTC) - SendCallback func(Event) // Callback for sending notifications + DBPath string // Path to SQLite database + BatchWindowSec int // Batching window in seconds (default 30) + MaxBatchSize int // Max events before forcing batch (default 5) + Location *time.Location // Timezone for quiet hours (default UTC) + SendCallback func(Event) // Callback for sending notifications } // New creates a new NotificationManager. @@ -134,13 +134,13 @@ func New(cfg Config) (*NotificationManager, error) { } m := &NotificationManager{ - db: db, - batchWindow: batchWindow, - maxBatchSize: maxBatchSize, - sendCallback: cfg.SendCallback, - location: location, - pendingLow: make([]*Event, 0, maxBatchSize), - pendingMedium: make([]*Event, 0, maxBatchSize), + db: db, + batchWindow: batchWindow, + maxBatchSize: maxBatchSize, + sendCallback: cfg.SendCallback, + location: location, + pendingLow: make([]*Event, 0, maxBatchSize), + pendingMedium: make([]*Event, 0, maxBatchSize), queuedForDigest: make([]*Event, 0), } @@ -301,11 +301,11 @@ func (m *NotificationManager) isQuietHours() bool { if quietFrom.Before(quietTo) { // Quiet hours don't cross midnight return (currentTime.Equal(quietFrom) || currentTime.After(quietFrom)) && - currentTime.Before(quietTo) + currentTime.Before(quietTo) } // Quiet hours cross midnight return currentTime.Equal(quietFrom) || currentTime.After(quietFrom) || - currentTime.Before(quietTo) + currentTime.Before(quietTo) } // isQuietHoursEnd checks if current time is at the quiet hours end time. @@ -483,14 +483,14 @@ func (m *NotificationManager) createSummary(events []*Event) Event { return Event{ ID: fmt.Sprintf("batch-%d", time.Now().UnixNano()), Type: ZoneEnter, // Generic type for summaries - Priority: Medium, // Summaries are medium priority + Priority: Medium, // Summaries are medium priority Title: title, Body: body, Timestamp: time.Now(), Data: map[string]interface{}{ - "event_count": len(events), - "type_counts": typeCounts, - "is_batch": true, + "event_count": len(events), + "type_counts": typeCounts, + "is_batch": true, }, } } @@ -588,9 +588,9 @@ func (m *NotificationManager) sendDigest() { Body: body, Timestamp: time.Now(), Data: map[string]interface{}{ - "event_count": len(queued), - "type_counts": typeCounts, - "is_digest": true, + "event_count": len(queued), + "type_counts": typeCounts, + "is_digest": true, }, } diff --git a/mothership/internal/notifications/manager_test.go b/mothership/internal/notifications/manager_test.go index 193bed2..de91bb0 100644 --- a/mothership/internal/notifications/manager_test.go +++ b/mothership/internal/notifications/manager_test.go @@ -774,10 +774,10 @@ func TestNotifyWithTimestamp(t *testing.T) { expectedTime := time.Date(2024, 4, 10, 12, 30, 0, 0, time.UTC) event := Event{ - Type: ZoneEnter, - Priority: Urgent, - Title: "Test", - Body: "Test", + Type: ZoneEnter, + Priority: Urgent, + Title: "Test", + Body: "Test", Timestamp: expectedTime, } @@ -1085,8 +1085,8 @@ func TestQuietHoursLowQueued(t *testing.T) { } m, err := New(Config{ - DBPath: dbPath, - Location: loc, + DBPath: dbPath, + Location: loc, SendCallback: func(e Event) {}, }) if err != nil { @@ -1099,7 +1099,7 @@ func TestQuietHoursLowQueued(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, // All days + QuietDaysBitmask: 0xFF, // All days MorningDigest: true, } @@ -1179,7 +1179,7 @@ func TestQuietHoursUrgentDelivered(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+1)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -1244,7 +1244,7 @@ func TestMorningDigestDelivery(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -1332,7 +1332,7 @@ func TestMorningDigestNotSentWhenDisabled(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: false, // Disabled } @@ -1411,7 +1411,7 @@ func TestHighPriorityDuringQuietHours(t *testing.T) { Channel: "default", QuietFrom: "00:00", QuietTo: "23:59", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } m.SetConfig(cfg) @@ -1452,8 +1452,8 @@ func TestMediumPriorityDuringQuietHours(t *testing.T) { } m, err := New(Config{ - DBPath: dbPath, - Location: loc, + DBPath: dbPath, + Location: loc, SendCallback: func(e Event) {}, }) if err != nil { @@ -1466,7 +1466,7 @@ func TestMediumPriorityDuringQuietHours(t *testing.T) { Channel: "default", QuietFrom: "00:00", QuietTo: "23:59", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } m.SetConfig(cfg) @@ -1519,7 +1519,7 @@ func TestQuietHoursNotActiveOutsideWindow(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:00", (now.Hour()+1)%24), // Next hour QuietTo: fmt.Sprintf("%02d:00", (now.Hour()+2)%24), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } m.SetConfig(cfg) @@ -1622,7 +1622,7 @@ func TestQuietHoursGate_LowAt23pmQueued(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, // All days + QuietDaysBitmask: 0xFF, // All days MorningDigest: true, } @@ -1719,7 +1719,7 @@ func TestQuietHoursGate_UrgentAt23pmDelivered(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, // All days + QuietDaysBitmask: 0xFF, // All days MorningDigest: true, } @@ -1802,7 +1802,7 @@ func TestQuietHoursGate_MediumAt23pmQueued(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, // All days + QuietDaysBitmask: 0xFF, // All days MorningDigest: true, } @@ -1871,7 +1871,7 @@ func TestQuietHoursGate_HighAt23pmDelivered(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+2)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -1953,7 +1953,7 @@ func TestMorningDigestOncePerDay(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -2018,7 +2018,7 @@ func TestMorningDigestEmptyNotSent(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -2059,7 +2059,7 @@ func TestIsQuietHoursEnd(t *testing.T) { Channel: "default", QuietFrom: "22:00", QuietTo: "07:00", - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: false, // Disabled } @@ -2121,7 +2121,7 @@ func TestMorningDigestIncludesAllEvents(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+2)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -2188,8 +2188,8 @@ func TestMorningDigestClearedAfterSend(t *testing.T) { } m, err := New(Config{ - DBPath: dbPath, - Location: loc, + DBPath: dbPath, + Location: loc, SendCallback: func(e Event) {}, }) if err != nil { @@ -2206,7 +2206,7 @@ func TestMorningDigestClearedAfterSend(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+2)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -2299,7 +2299,7 @@ func TestMorningDigestWithMixedPriorities(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+2)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } @@ -2360,7 +2360,7 @@ func TestMorningDigestTitleFormat(t *testing.T) { Channel: "default", QuietFrom: fmt.Sprintf("%02d:%02d", currentHour, currentMinute), QuietTo: fmt.Sprintf("%02d:%02d", (currentHour+2)%24, currentMinute), - QuietDaysBitmask: 0xFF, + QuietDaysBitmask: 0xFF, MorningDigest: true, } diff --git a/mothership/internal/notifications/ntfy.go b/mothership/internal/notifications/ntfy.go index 46ab49e..e85b254 100644 --- a/mothership/internal/notifications/ntfy.go +++ b/mothership/internal/notifications/ntfy.go @@ -63,10 +63,10 @@ type NtfyMessage struct { // NewNtfyClient creates a new ntfy client with default settings. func NewNtfyClient(topic string) *NtfyClient { return &NtfyClient{ - URL: "https://ntfy.sh", - Topic: topic, - Priority: "default", - Tags: nil, + URL: "https://ntfy.sh", + Topic: topic, + Priority: "default", + Tags: nil, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, diff --git a/mothership/internal/notifications/ntfy_test.go b/mothership/internal/notifications/ntfy_test.go index 73e404f..3989739 100644 --- a/mothership/internal/notifications/ntfy_test.go +++ b/mothership/internal/notifications/ntfy_test.go @@ -279,7 +279,7 @@ func TestNtfyClientDefaults(t *testing.T) { func TestAttachPNGImage(t *testing.T) { pngData := []byte{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x00, 0x00, 0x00, 0x0D, // IHDR length } result := AttachPNGImage(pngData) diff --git a/mothership/internal/notifications/pushover.go b/mothership/internal/notifications/pushover.go index 15c9f87..70e9592 100644 --- a/mothership/internal/notifications/pushover.go +++ b/mothership/internal/notifications/pushover.go @@ -49,31 +49,31 @@ type PushoverClient struct { // PushoverMessage represents a notification message for Pushover. type PushoverMessage struct { - Message string - Title string - Priority int - Device string - URL string - URLTitle string - Sound string - Timestamp int64 + Message string + Title string + Priority int + Device string + URL string + URLTitle string + Sound string + Timestamp int64 // PNGImageData is optional PNG image data to attach PNGImageData []byte // Emergency settings for priority 2 - Retry int // Retry in seconds (min 30) - Expire int // Expire in seconds (max 10800) + Retry int // Retry in seconds (min 30) + Expire int // Expire in seconds (max 10800) } // NewPushoverClient creates a new Pushover client. func NewPushoverClient(appToken, userKey string) *PushoverClient { return &PushoverClient{ - AppToken: appToken, - UserKey: userKey, - APIURL: "https://api.pushover.net/1/messages.json", - Priority: 0, - Sound: "pushover", + AppToken: appToken, + UserKey: userKey, + APIURL: "https://api.pushover.net/1/messages.json", + Priority: 0, + Sound: "pushover", HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, diff --git a/mothership/internal/notifications/pushover_test.go b/mothership/internal/notifications/pushover_test.go index 3a5390a..b32113b 100644 --- a/mothership/internal/notifications/pushover_test.go +++ b/mothership/internal/notifications/pushover_test.go @@ -367,7 +367,7 @@ func TestPushoverEmergencySettings(t *testing.T) { msg := PushoverMessage{ Message: "Emergency!", Priority: 2, - Retry: 60, // Retry every 60 seconds + Retry: 60, // Retry every 60 seconds Expire: 3600, // Expire after 1 hour } @@ -674,14 +674,14 @@ func TestPushoverSendWithAllOptions(t *testing.T) { client.APIURL = server.URL msg := PushoverMessage{ - Message: "Full message", - Title: "Full Title", - Priority: 1, - Device: "iphone", - URL: "https://example.com", - URLTitle: "Example Site", - Sound: "cosmic", - Timestamp: 1234567890, + Message: "Full message", + Title: "Full Title", + Priority: 1, + Device: "iphone", + URL: "https://example.com", + URLTitle: "Example Site", + Sound: "cosmic", + Timestamp: 1234567890, } err := client.Send(msg) @@ -766,7 +766,7 @@ func TestPushoverRetryExpireClamping(t *testing.T) { msg := PushoverMessage{ Message: "Emergency", Priority: 2, - Retry: 10, // Below minimum of 30 + Retry: 10, // Below minimum of 30 Expire: 20000, // Above maximum of 10800 } diff --git a/mothership/internal/notifications/webhook.go b/mothership/internal/notifications/webhook.go index 6297915..5b6bb0b 100644 --- a/mothership/internal/notifications/webhook.go +++ b/mothership/internal/notifications/webhook.go @@ -32,29 +32,29 @@ type WebhookClient struct { // WebhookPayload represents the JSON payload sent to webhook URLs. type WebhookPayload struct { - EventType string `json:"event_type"` - Message string `json:"message"` - Title string `json:"title,omitempty"` - Priority string `json:"priority,omitempty"` - PersonID string `json:"person_id,omitempty"` - PersonName string `json:"person_name,omitempty"` - ZoneID string `json:"zone_id,omitempty"` - ZoneName string `json:"zone_name,omitempty"` - Timestamp int64 `json:"timestamp"` - TimestampISO string `json:"timestamp_iso"` + EventType string `json:"event_type"` + Message string `json:"message"` + Title string `json:"title,omitempty"` + Priority string `json:"priority,omitempty"` + PersonID string `json:"person_id,omitempty"` + PersonName string `json:"person_name,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Timestamp int64 `json:"timestamp"` + TimestampISO string `json:"timestamp_iso"` FloorplanPNGBase64 string `json:"floorplan_png_base64,omitempty"` // Additional fields for specific event types - BlobID *int `json:"blob_id,omitempty"` - BlobX *float64 `json:"blob_x,omitempty"` - BlobY *float64 `json:"blob_y,omitempty"` - BlobZ *float64 `json:"blob_z,omitempty"` - Confidence *float64 `json:"confidence,omitempty"` + BlobID *int `json:"blob_id,omitempty"` + BlobX *float64 `json:"blob_x,omitempty"` + BlobY *float64 `json:"blob_y,omitempty"` + BlobZ *float64 `json:"blob_z,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` // Node information for node events - NodeMAC string `json:"node_mac,omitempty"` - NodeName string `json:"node_name,omitempty"` - NodeRole string `json:"node_role,omitempty"` + NodeMAC string `json:"node_mac,omitempty"` + NodeName string `json:"node_name,omitempty"` + NodeRole string `json:"node_role,omitempty"` // Additional metadata Metadata map[string]interface{} `json:"metadata,omitempty"` diff --git a/mothership/internal/notify/service.go b/mothership/internal/notify/service.go index 5b2d7ff..0cfc940 100644 --- a/mothership/internal/notify/service.go +++ b/mothership/internal/notify/service.go @@ -27,22 +27,22 @@ import ( type NotificationChannel string const ( - ChannelNtfy NotificationChannel = "ntfy" + ChannelNtfy NotificationChannel = "ntfy" ChannelPushover NotificationChannel = "pushover" ChannelGotify NotificationChannel = "gotify" - ChannelWebhook NotificationChannel = "webhook" + ChannelWebhook NotificationChannel = "webhook" ) // Notification represents a notification to send. type Notification struct { - Title string `json:"title"` - Body string `json:"body"` - Priority int `json:"priority,omitempty"` // 1-5 for Pushover - Tags []string `json:"tags,omitempty"` - Image []byte `json:"-"` // Base64 encoded image - ImageType string `json:"image_type,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Timestamp time.Time `json:"timestamp"` + Title string `json:"title"` + Body string `json:"body"` + Priority int `json:"priority,omitempty"` // 1-5 for Pushover + Tags []string `json:"tags,omitempty"` + Image []byte `json:"-"` // Base64 encoded image + ImageType string `json:"image_type,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Timestamp time.Time `json:"timestamp"` } // ChannelConfig holds configuration for a notification channel. @@ -54,7 +54,7 @@ type ChannelConfig struct { User string // pushover user key Username string // basic auth username Password string // basic auth password - Headers map[string]string // custom headers for webhook + Headers map[string]string // custom headers for webhook } // QuietHoursConfig holds quiet hours configuration. @@ -68,21 +68,21 @@ type QuietHoursConfig struct { // Service manages notification delivery. type Service struct { - mu sync.RWMutex - db *sql.DB - channels map[string]*ChannelConfig - quietHours *QuietHoursConfig - httpClient *http.Client - roomConfig RoomConfigProvider - floorPlan []byte // Cached floor plan image + mu sync.RWMutex + db *sql.DB + channels map[string]*ChannelConfig + quietHours *QuietHoursConfig + httpClient *http.Client + roomConfig RoomConfigProvider + floorPlan []byte // Cached floor plan image // Batching - pending []batchedNotification - batchTimer *time.Timer - batchWindow time.Duration + pending []batchedNotification + batchTimer *time.Timer + batchWindow time.Duration // Callbacks - onSend func(channel string, notif Notification, success bool) + onSend func(channel string, notif Notification, success bool) } type batchedNotification struct { @@ -556,7 +556,6 @@ func (s *Service) sendGotify(cc *ChannelConfig, notif Notification) error { return nil } - // sendWebhook sends notification via custom webhook. func (s *Service) sendWebhook(cc *ChannelConfig, notif Notification) error { if cc.URL == "" { @@ -609,7 +608,7 @@ func (s *Service) sendWebhook(cc *ChannelConfig, notif Notification) error { // GenerateFloorPlanThumbnail generates a mini floor plan PNG with blob positions. func (s *Service) GenerateFloorPlanThumbnail(width, height int, blobs []struct { - X, Y, Z float64 + X, Y, Z float64 Identity string IsFall bool }) ([]byte, error) { diff --git a/mothership/internal/notify/service_delivery_test.go b/mothership/internal/notify/service_delivery_test.go index fe5ce1e..2e3b7f2 100644 --- a/mothership/internal/notify/service_delivery_test.go +++ b/mothership/internal/notify/service_delivery_test.go @@ -403,7 +403,7 @@ func TestBatchingWindowExpiry(t *testing.T) { // Set short batch window ext.SetBatchingConfig(BatchingConfig{ Enabled: true, - BatchWindowSec: 1, // 1 second window + BatchWindowSec: 1, // 1 second window MaxBatchSize: 100, // High max size, won't trigger flush BatchLowPriority: true, BatchMedium: true, @@ -452,10 +452,10 @@ func TestQuietHoursCrossMidnight(t *testing.T) { // Test various times testCases := []struct { - hour int - min int - expected bool - desc string + hour int + min int + expected bool + desc string }{ {21, 59, false, "21:59 - before quiet hours"}, {22, 0, true, "22:00 - quiet hours start"}, @@ -698,9 +698,9 @@ func TestFloorPlanRendererBlobClamping(t *testing.T) { Identity string IsFall bool }{ - {X: -1.0, Z: -1.0}, // Outside (negative) + {X: -1.0, Z: -1.0}, // Outside (negative) {X: 100.0, Z: 100.0}, // Outside (beyond room) - {X: 3.0, Z: 2.5}, // Inside + {X: 3.0, Z: 2.5}, // Inside } // Should not panic with out-of-bounds blobs @@ -1258,8 +1258,8 @@ func TestMorningDigestPreventsDuplicateSend(t *testing.T) { ext.mu.Unlock() today := time.Now().Format("2006-01-02") - ext.digestLastDate = today // Simulate already sent today - ext.digestSentToday = true // Also need to set this flag + ext.digestLastDate = today // Simulate already sent today + ext.digestSentToday = true // Also need to set this flag // Try to send digest ext.sendMorningDigest() diff --git a/mothership/internal/notify/service_enhanced.go b/mothership/internal/notify/service_enhanced.go index 7a8cdc7..924acaa 100644 --- a/mothership/internal/notify/service_enhanced.go +++ b/mothership/internal/notify/service_enhanced.go @@ -33,22 +33,22 @@ const ( type NotificationType string const ( - TypeZoneEnter NotificationType = "zone_enter" - TypeZoneLeave NotificationType = "zone_leave" - TypeZoneVacant NotificationType = "zone_vacant" - TypeFallDetected NotificationType = "fall_detected" - TypeFallEscalation NotificationType = "fall_escalation" - TypeAnomalyAlert NotificationType = "anomaly_alert" - TypeNodeOffline NotificationType = "node_offline" - TypeSleepSummary NotificationType = "sleep_summary" - TypeMorningBriefing NotificationType = "morning_briefing" + TypeZoneEnter NotificationType = "zone_enter" + TypeZoneLeave NotificationType = "zone_leave" + TypeZoneVacant NotificationType = "zone_vacant" + TypeFallDetected NotificationType = "fall_detected" + TypeFallEscalation NotificationType = "fall_escalation" + TypeAnomalyAlert NotificationType = "anomaly_alert" + TypeNodeOffline NotificationType = "node_offline" + TypeSleepSummary NotificationType = "sleep_summary" + TypeMorningBriefing NotificationType = "morning_briefing" ) // BatchingConfig holds smart batching configuration. type BatchingConfig struct { Enabled bool - BatchWindowSec int // Window duration in seconds (default 30) - MaxBatchSize int // Maximum events before forcing send (default 5) + BatchWindowSec int // Window duration in seconds (default 30) + MaxBatchSize int // Maximum events before forcing send (default 5) BatchLowPriority bool // Batch LOW priority events BatchMedium bool // Batch MEDIUM priority events } @@ -62,8 +62,8 @@ type QuietHoursConfigExtended struct { EndMin int QuietDaysMask uint8 // Bitmask for days (0=Sunday, 1=Monday, etc.) MorningDigest bool // Deliver queued events as morning digest - DigestHour int // Hour to deliver morning digest (default 7) - DigestMin int // Minute to deliver morning digest + DigestHour int // Hour to deliver morning digest (default 7) + DigestMin int // Minute to deliver morning digest } // ExtendedService extends the base Service with batching and quiet hours. @@ -71,10 +71,10 @@ type ExtendedService struct { *Service // Batching state - pendingLow []Notification - pendingMedium []Notification - batchTimer *time.Timer - lastBatchFlush time.Time + pendingLow []Notification + pendingMedium []Notification + batchTimer *time.Timer + lastBatchFlush time.Time // Quiet hours queue queuedDuringQuiet []Notification @@ -390,10 +390,10 @@ func (ext *ExtendedService) sendMorningDigest() { body := fmt.Sprintf("While you were asleep: %s", strings.Join(bodies, ". ")) digestNotif := Notification{ - Title: title, - Body: body, - Priority: int(PriorityLow), - Tags: []string{"digest", "morning"}, + Title: title, + Body: body, + Priority: int(PriorityLow), + Tags: []string{"digest", "morning"}, Data: map[string]interface{}{ "event_count": len(queued), }, diff --git a/mothership/internal/notify/service_enhanced_test.go b/mothership/internal/notify/service_enhanced_test.go index 7c2020b..ce9430d 100644 --- a/mothership/internal/notify/service_enhanced_test.go +++ b/mothership/internal/notify/service_enhanced_test.go @@ -518,9 +518,9 @@ func TestSendUrgentPriorityImmediate(t *testing.T) { // Low-priority is suppressed during quiet hours service.Send(Notification{ - Title: "Low priority", - Body: "suppressed", - Priority: int(PriorityLow), + Title: "Low priority", + Body: "suppressed", + Priority: int(PriorityLow), Timestamp: time.Now(), }) @@ -940,8 +940,8 @@ func TestFloorPlanThumbnailBlobColors(t *testing.T) { defer service.Close() //nolint:errcheck tests := []struct { - name string - blob struct { + name string + blob struct { X, Y, Z float64 Identity string IsFall bool @@ -952,18 +952,30 @@ func TestFloorPlanThumbnailBlobColors(t *testing.T) { wantBlueDominant bool }{ { - name: "identified blob is green-ish", - blob: struct{ X, Y, Z float64; Identity string; IsFall bool }{X: 2.0, Z: 1.5, Identity: "Alice"}, + name: "identified blob is green-ish", + blob: struct { + X, Y, Z float64 + Identity string + IsFall bool + }{X: 2.0, Z: 1.5, Identity: "Alice"}, wantGreenDominant: true, }, { - name: "fall blob is red-ish", - blob: struct{ X, Y, Z float64; Identity string; IsFall bool }{X: 2.0, Z: 1.5, IsFall: true}, + name: "fall blob is red-ish", + blob: struct { + X, Y, Z float64 + Identity string + IsFall bool + }{X: 2.0, Z: 1.5, IsFall: true}, wantRedDominant: true, }, { - name: "unknown blob is blue-ish", - blob: struct{ X, Y, Z float64; Identity string; IsFall bool }{X: 2.0, Z: 1.5}, + name: "unknown blob is blue-ish", + blob: struct { + X, Y, Z float64 + Identity string + IsFall bool + }{X: 2.0, Z: 1.5}, wantBlueDominant: true, }, } @@ -1011,9 +1023,10 @@ func TestFloorPlanThumbnailBlobColors(t *testing.T) { // TestFloorPlanZoneBoundaryPixels verifies that zone outlines appear at the expected pixel coordinates // in the extended renderer. The coordinate transform for a 300x300 image with 6x5 room: -// margin=10, drawW=280, drawH=280 -// scaleX=280/6≈46.67, scaleZ=280/5=56 → scale=min(46.67,56)=46.67 -// offsetX=10+(280-6*46.67)/2=10, offsetY=10+(280-5*46.67)/2≈33 +// +// margin=10, drawW=280, drawH=280 +// scaleX=280/6≈46.67, scaleZ=280/5=56 → scale=min(46.67,56)=46.67 +// offsetX=10+(280-6*46.67)/2=10, offsetY=10+(280-5*46.67)/2≈33 // // A zone at (X=0, Y=0, W=2, D=2) → top-left at (px=10, py=33), bottom-right at ~(px=103, py=127). func TestFloorPlanZoneBoundaryPixels(t *testing.T) { diff --git a/mothership/internal/ota/autoapi.go b/mothership/internal/ota/autoapi.go index f054e37..a71eb8d 100644 --- a/mothership/internal/ota/autoapi.go +++ b/mothership/internal/ota/autoapi.go @@ -95,15 +95,15 @@ func (h *AutoAPIHandler) handleStatus(w http.ResponseWriter, r *http.Request) { baselineQuality := h.mgr.GetBaselineQuality() response := map[string]interface{}{ - "enabled": config.Enabled, - "state": string(state), - "canary_node": canaryNode, - "baseline_quality": baselineQuality, - "quiet_window_start": config.QuietWindowStart, - "quiet_window_end": config.QuietWindowEnd, + "enabled": config.Enabled, + "state": string(state), + "canary_node": canaryNode, + "baseline_quality": baselineQuality, + "quiet_window_start": config.QuietWindowStart, + "quiet_window_end": config.QuietWindowEnd, "canary_duration_min": config.CanaryDurationMin, - "quality_threshold": config.QualityThreshold, - "is_in_quiet_window": h.isInQuietWindow(config), + "quality_threshold": config.QualityThreshold, + "is_in_quiet_window": h.isInQuietWindow(config), } // Add canary progress info if in canary state @@ -159,12 +159,12 @@ func (h *AutoAPIHandler) handleConfig(w http.ResponseWriter, r *http.Request) { config := h.mgr.GetConfig() response := map[string]interface{}{ - "enabled": config.Enabled, - "quiet_window_start": config.QuietWindowStart, - "quiet_window_end": config.QuietWindowEnd, - "canary_duration_min": config.CanaryDurationMin, - "quality_threshold": config.QualityThreshold, - "is_in_quiet_window": h.isInQuietWindow(config), + "enabled": config.Enabled, + "quiet_window_start": config.QuietWindowStart, + "quiet_window_end": config.QuietWindowEnd, + "canary_duration_min": config.CanaryDurationMin, + "quality_threshold": config.QualityThreshold, + "is_in_quiet_window": h.isInQuietWindow(config), "next_quiet_window_start": h.nextQuietWindowStart(config), } diff --git a/mothership/internal/ota/autoupdate.go b/mothership/internal/ota/autoupdate.go index 9a5aa1a..8bc3a53 100644 --- a/mothership/internal/ota/autoupdate.go +++ b/mothership/internal/ota/autoupdate.go @@ -12,14 +12,14 @@ import ( // AutoUpdateManager manages automatic OTA updates with canary deployment and quiet window scheduling. type AutoUpdateManager struct { - mu sync.RWMutex - server *Server - otaManager *Manager - settingsProvider SettingsProvider - qualityProvider QualityProvider - nodeProvider NodeProvider - notifier EventNotifier - timezone *time.Location + mu sync.RWMutex + server *Server + otaManager *Manager + settingsProvider SettingsProvider + qualityProvider QualityProvider + nodeProvider NodeProvider + notifier EventNotifier + timezone *time.Location zoneVacancyChecker ZoneVacancyChecker // State @@ -66,10 +66,10 @@ type ZoneVacancyChecker interface { type UpdateState string const ( - StateIdle UpdateState = "idle" - StateChecking UpdateState = "checking" - StateWaitingWindow UpdateState = "waiting_window" - StateCanaryDeploy UpdateState = "canary_deploy" + StateIdle UpdateState = "idle" + StateChecking UpdateState = "checking" + StateWaitingWindow UpdateState = "waiting_window" + StateCanaryDeploy UpdateState = "canary_deploy" StateCanaryMonitor UpdateState = "canary_monitor" StateFleetDeploy UpdateState = "fleet_deploy" StateRollback UpdateState = "rollback" @@ -79,11 +79,11 @@ const ( // AutoUpdateConfig holds the configuration for auto-updates. type AutoUpdateConfig struct { - Enabled bool `json:"enabled"` - QuietWindowStart string `json:"quiet_window_start"` // HH:MM format - QuietWindowEnd string `json:"quiet_window_end"` // HH:MM format - CanaryDurationMin int `json:"canary_duration_min"` // Canary monitoring duration - QualityThreshold float64 `json:"quality_threshold"` // Quality degradation threshold (0-1) + Enabled bool `json:"enabled"` + QuietWindowStart string `json:"quiet_window_start"` // HH:MM format + QuietWindowEnd string `json:"quiet_window_end"` // HH:MM format + CanaryDurationMin int `json:"canary_duration_min"` // Canary monitoring duration + QualityThreshold float64 `json:"quality_threshold"` // Quality degradation threshold (0-1) } // DefaultAutoUpdateConfig returns the default auto-update configuration. @@ -92,7 +92,7 @@ func DefaultAutoUpdateConfig() AutoUpdateConfig { Enabled: false, QuietWindowStart: "02:00", QuietWindowEnd: "05:00", - CanaryDurationMin: 10, + CanaryDurationMin: 10, QualityThreshold: 0.05, // 5% degradation threshold } } @@ -100,10 +100,10 @@ func DefaultAutoUpdateConfig() AutoUpdateConfig { // NewAutoUpdateManager creates a new auto-update manager. func NewAutoUpdateManager(server *Server, otaMgr *Manager, timezone *time.Location) *AutoUpdateManager { return &AutoUpdateManager{ - server: server, - otaManager: otaMgr, - timezone: timezone, - updateState: StateIdle, + server: server, + otaManager: otaMgr, + timezone: timezone, + updateState: StateIdle, } } @@ -514,7 +514,7 @@ func (m *AutoUpdateManager) evaluateCanary(ctx context.Context, firmware *Firmwa qualityChanged := math.Abs(qualityDelta) m.publishEvent("canary_evaluated", canaryMAC, fmt.Sprintf("Canary evaluation: quality delta %.2f%%", qualityDelta*100), map[string]interface{}{ - "baseline_quality": baselineQuality, + "baseline_quality": baselineQuality, "current_quality": currentQuality, "quality_delta": qualityDelta, }) @@ -527,8 +527,8 @@ func (m *AutoUpdateManager) evaluateCanary(ctx context.Context, firmware *Firmwa m.mu.Unlock() m.publishEvent("canary_failed", canaryMAC, fmt.Sprintf("Canary quality degraded %.2f%%, aborting update", qualityDelta*100), map[string]interface{}{ - "threshold": config.QualityThreshold, - "quality_delta": qualityDelta, + "threshold": config.QualityThreshold, + "quality_delta": qualityDelta, }) log.Printf("[WARN] ota: canary quality degraded %.2f%% (threshold %.2f%%), aborting auto-update", diff --git a/mothership/internal/ota/autoupdate_test.go b/mothership/internal/ota/autoupdate_test.go index 294cb0c..56f42d5 100644 --- a/mothership/internal/ota/autoupdate_test.go +++ b/mothership/internal/ota/autoupdate_test.go @@ -18,9 +18,9 @@ func newMockSettingsProvider() *mockSettingsProvider { return &mockSettingsProvider{ values: map[string]interface{}{ "auto_update_enabled": false, - "quiet_window_start": "02:00", - "quiet_window_end": "05:00", - "canary_duration_min": float64(10), + "quiet_window_start": "02:00", + "quiet_window_end": "05:00", + "canary_duration_min": float64(10), "auto_update_quality_threshold": 0.05, }, } @@ -156,8 +156,8 @@ func (e *mockNodeNotFoundError) Error() string { // mockEventNotifier is a test implementation of EventNotifier. type mockEventNotifier struct { - mu sync.RWMutex - events []mockEvent + mu sync.RWMutex + events []mockEvent } type mockEvent struct { @@ -198,8 +198,8 @@ func (m *mockEventNotifier) clear() { // mockZoneVacancyChecker is a test implementation of ZoneVacancyChecker. type mockZoneVacancyChecker struct { - mu sync.RWMutex - vacant bool + mu sync.RWMutex + vacant bool } func newMockZoneVacancyChecker() *mockZoneVacancyChecker { @@ -317,11 +317,11 @@ func TestIsInQuietWindow(t *testing.T) { _ = NewAutoUpdateManager(srv, mgr, tz) tests := []struct { - name string - start string - end string - testTime string - wantIn bool + name string + start string + end string + testTime string + wantIn bool }{ { name: "inside window", diff --git a/mothership/internal/ota/manager.go b/mothership/internal/ota/manager.go index 85352a7..e55e1fe 100644 --- a/mothership/internal/ota/manager.go +++ b/mothership/internal/ota/manager.go @@ -12,13 +12,13 @@ import ( type NodeOTAState int const ( - OTAIdle NodeOTAState = iota - OTAPending // queued for update - OTADownloading // node is downloading firmware - OTARebooting // node rebooted into new partition - OTAVerified // node reconnected with new version - OTAFailed // download or verification failed - OTARollback // node came back with old version + OTAIdle NodeOTAState = iota + OTAPending // queued for update + OTADownloading // node is downloading firmware + OTARebooting // node rebooted into new partition + OTAVerified // node reconnected with new version + OTAFailed // download or verification failed + OTARollback // node came back with old version ) func (s NodeOTAState) String() string { @@ -66,12 +66,12 @@ type DashboardBroadcaster interface { // Manager orchestrates rolling OTA updates across the fleet. type Manager struct { - mu sync.RWMutex - server *Server - sender NodeSender + mu sync.RWMutex + server *Server + sender NodeSender broadcaster DashboardBroadcaster - progress map[string]*NodeOTAProgress - baseURL string // e.g. "http://mothership:8080" + progress map[string]*NodeOTAProgress + baseURL string // e.g. "http://mothership:8080" } // NewManager creates an OTA manager. diff --git a/mothership/internal/ota/server.go b/mothership/internal/ota/server.go index af36539..40f200a 100644 --- a/mothership/internal/ota/server.go +++ b/mothership/internal/ota/server.go @@ -32,10 +32,10 @@ type FirmwareUploadCallback func(filename string) // Server serves firmware binaries and tracks available versions. type Server struct { - mu sync.RWMutex - firmwareDir string - firmware map[string]*FirmwareMeta - latestFile string + mu sync.RWMutex + firmwareDir string + firmware map[string]*FirmwareMeta + latestFile string uploadCallback FirmwareUploadCallback } diff --git a/mothership/internal/oui/gen.go b/mothership/internal/oui/gen.go index 133f4bb..6fe82d9 100644 --- a/mothership/internal/oui/gen.go +++ b/mothership/internal/oui/gen.go @@ -2,5 +2,6 @@ // for MAC addresses to determine manufacturer names. // // Generate OUI data with: go generate +// //go:generate go run gen_data.go > oui_data.go package oui diff --git a/mothership/internal/oui/gen_data.go b/mothership/internal/oui/gen_data.go index 38f5238..688dd26 100644 --- a/mothership/internal/oui/gen_data.go +++ b/mothership/internal/oui/gen_data.go @@ -15,7 +15,7 @@ import ( // OUI entry from IEEE registry type OUIEntry struct { - OUI string + OUI string Manufacturer string } @@ -89,7 +89,7 @@ func parseOUIRegistry(r io.Reader) []OUIEntry { } entries = append(entries, OUIEntry{ - OUI: oui, + OUI: oui, Manufacturer: manufacturer, }) } diff --git a/mothership/internal/oui/oui_test.go b/mothership/internal/oui/oui_test.go index fc0ee1c..4622881 100644 --- a/mothership/internal/oui/oui_test.go +++ b/mothership/internal/oui/oui_test.go @@ -7,10 +7,10 @@ import ( func TestLookupOUI(t *testing.T) { tests := []struct { - name string - mac string - want string - wantEmpty bool + name string + mac string + want string + wantEmpty bool }{ { name: "Apple OUI", diff --git a/mothership/internal/prediction/accuracy_test.go b/mothership/internal/prediction/accuracy_test.go index fb0a3c9..4bfa2df 100644 --- a/mothership/internal/prediction/accuracy_test.go +++ b/mothership/internal/prediction/accuracy_test.go @@ -179,33 +179,33 @@ func TestAccuracyTracker_ZoneOccupancy(t *testing.T) { func TestHourOfWeek(t *testing.T) { tests := []struct { - name string - time time.Time + name string + time time.Time hourOfWeek int }{ { - name: "Sunday midnight", - time: time.Date(2024, 1, 7, 0, 0, 0, 0, time.UTC), // Sunday + name: "Sunday midnight", + time: time.Date(2024, 1, 7, 0, 0, 0, 0, time.UTC), // Sunday hourOfWeek: 0, }, { - name: "Sunday 1am", - time: time.Date(2024, 1, 7, 1, 0, 0, 0, time.UTC), + name: "Sunday 1am", + time: time.Date(2024, 1, 7, 1, 0, 0, 0, time.UTC), hourOfWeek: 1, }, { - name: "Monday midnight", - time: time.Date(2024, 1, 8, 0, 0, 0, 0, time.UTC), // Monday + name: "Monday midnight", + time: time.Date(2024, 1, 8, 0, 0, 0, 0, time.UTC), // Monday hourOfWeek: 24, }, { - name: "Monday noon", - time: time.Date(2024, 1, 8, 12, 0, 0, 0, time.UTC), + name: "Monday noon", + time: time.Date(2024, 1, 8, 12, 0, 0, 0, time.UTC), hourOfWeek: 36, }, { - name: "Saturday 11pm", - time: time.Date(2024, 1, 6, 23, 0, 0, 0, time.UTC), // Saturday + name: "Saturday 11pm", + time: time.Date(2024, 1, 6, 23, 0, 0, 0, time.UTC), // Saturday hourOfWeek: 167, }, } diff --git a/mothership/internal/prediction/adapter.go b/mothership/internal/prediction/adapter.go index 222fcfe..345fa7c 100644 --- a/mothership/internal/prediction/adapter.go +++ b/mothership/internal/prediction/adapter.go @@ -47,8 +47,8 @@ func (a *ZoneAdapter) GetZone(id string) (string, bool) { // PersonAdapter provides person information from the BLE registry. type PersonAdapter struct { - mu sync.RWMutex - people map[string]struct { + mu sync.RWMutex + people map[string]struct { Name string Color string } diff --git a/mothership/internal/prediction/history.go b/mothership/internal/prediction/history.go index ac165b9..09a107b 100644 --- a/mothership/internal/prediction/history.go +++ b/mothership/internal/prediction/history.go @@ -30,7 +30,7 @@ type HistoryUpdater struct { // NewHistoryUpdater creates a new history updater. func NewHistoryUpdater(store *ModelStore) *HistoryUpdater { return &HistoryUpdater{ - store: store, + store: store, personZones: make(map[string]struct { ZoneID string EntryTime time.Time diff --git a/mothership/internal/prediction/horizon.go b/mothership/internal/prediction/horizon.go index 4d3cf39..436b4a3 100644 --- a/mothership/internal/prediction/horizon.go +++ b/mothership/internal/prediction/horizon.go @@ -12,14 +12,14 @@ import ( type HorizonPredictor struct { mu sync.RWMutex - store *ModelStore - accuracyTracker *AccuracyTracker - zoneProvider ZoneInfoProvider - personProvider PersonInfoProvider + store *ModelStore + accuracyTracker *AccuracyTracker + zoneProvider ZoneInfoProvider + personProvider PersonInfoProvider positionProvider CurrentPositionProvider // Configuration - horizon time.Duration + horizon time.Duration monteCarloRuns int // Number of Monte Carlo simulations // Random source for simulations @@ -74,19 +74,19 @@ func (h *HorizonPredictor) SetMonteCarloRuns(n int) { // HorizonPrediction represents a prediction at a specific time horizon. type HorizonPrediction struct { - PersonID string `json:"person_id"` - PersonLabel string `json:"person_label"` - CurrentZoneID string `json:"current_zone_id"` - CurrentZoneName string `json:"current_zone_name"` - PredictedZoneID string `json:"predicted_zone_id"` - PredictedZoneName string `json:"predicted_zone_name"` - HorizonMinutes int `json:"horizon_minutes"` - Confidence float64 `json:"confidence"` + PersonID string `json:"person_id"` + PersonLabel string `json:"person_label"` + CurrentZoneID string `json:"current_zone_id"` + CurrentZoneName string `json:"current_zone_name"` + PredictedZoneID string `json:"predicted_zone_id"` + PredictedZoneName string `json:"predicted_zone_name"` + HorizonMinutes int `json:"horizon_minutes"` + Confidence float64 `json:"confidence"` ZoneProbabilities map[string]float64 `json:"zone_probabilities"` // zoneID -> probability - DataConfidence string `json:"data_confidence"` - SampleCount int `json:"sample_count"` - ModelReady bool `json:"model_ready"` - EstimatedTransitions int `json:"estimated_transitions"` + DataConfidence string `json:"data_confidence"` + SampleCount int `json:"sample_count"` + ModelReady bool `json:"model_ready"` + EstimatedTransitions int `json:"estimated_transitions"` } // PredictAtHorizon predicts where a person will be at the specified horizon. @@ -256,7 +256,7 @@ func (h *HorizonPredictor) sampleNormal(mean, stddev float64) float64 { h.mu.Unlock() } - z := sqrtFast(-2 * logFast(u1)) * cosFast(2 * 3.14159265359 * u2) + z := sqrtFast(-2*logFast(u1)) * cosFast(2*3.14159265359*u2) return mean + stddev*z } diff --git a/mothership/internal/provisioning/server.go b/mothership/internal/provisioning/server.go index 5822e68..e07d9a0 100644 --- a/mothership/internal/provisioning/server.go +++ b/mothership/internal/provisioning/server.go @@ -27,8 +27,8 @@ type Payload struct { WifiPass string `json:"wifi_pass"` NodeID string `json:"node_id"` NodeToken string `json:"node_token"` - MsMDNS string `json:"ms_mdns"` - MsIP string `json:"ms_ip,omitempty"` // Direct IPv4 override for mDNS-less networks + MsMDNS string `json:"ms_mdns"` + MsIP string `json:"ms_ip,omitempty"` // Direct IPv4 override for mDNS-less networks MsPort int `json:"ms_port"` NTPServer string `json:"ntp_server"` Debug bool `json:"debug"` @@ -181,16 +181,16 @@ func (s *Server) HandleProvision(w http.ResponseWriter, r *http.Request) { } payload := Payload{ - Version: 1, - WifiSSID: req.WifiSSID, - WifiPass: req.WifiPass, - NodeID: nodeID, - NodeToken: token, - MsMDNS: s.mdnsName, - MsIP: req.MsIP, - MsPort: s.msPort, - NTPServer: s.ntpServer, - Debug: req.Debug, + Version: 1, + WifiSSID: req.WifiSSID, + WifiPass: req.WifiPass, + NodeID: nodeID, + NodeToken: token, + MsMDNS: s.mdnsName, + MsIP: req.MsIP, + MsPort: s.msPort, + NTPServer: s.ntpServer, + Debug: req.Debug, } log.Printf("[INFO] provisioning: generated payload node_id=%s mac=%s", nodeID, req.MAC) diff --git a/mothership/internal/render/floorplan.go b/mothership/internal/render/floorplan.go index 865f224..050d0a3 100644 --- a/mothership/internal/render/floorplan.go +++ b/mothership/internal/render/floorplan.go @@ -26,14 +26,14 @@ const ( // Zone represents a zone in the floor plan. type Zone struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - W float64 `json:"w"` - D float64 `json:"d"` - Color string `json:"color"` - Highlight bool `json:"highlight"` // Highlight this zone (event location) + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + D float64 `json:"d"` + Color string `json:"color"` + Highlight bool `json:"highlight"` // Highlight this zone (event location) } // Node represents a node position. @@ -46,46 +46,46 @@ type Node struct { // Person represents a tracked person. type Person struct { - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - Z float64 `json:"z"` - Color string `json:"color"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + Color string `json:"color"` Confidence float64 `json:"confidence"` - IsFall bool `json:"is_fall"` + IsFall bool `json:"is_fall"` } // Portal represents a portal between zones. type Portal struct { - Name string `json:"name"` - X1 float64 `json:"x1"` - Y1 float64 `json:"y1"` - X2 float64 `json:"x2"` - Y2 float64 `json:"y2"` + Name string `json:"name"` + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + X2 float64 `json:"x2"` + Y2 float64 `json:"y2"` } // RenderConfig holds configuration for floor-plan rendering. type RenderConfig struct { - Width int // Image width in pixels (default 300) - Height int // Image height in pixels (default 300) - RoomWidth float64 // Room width in meters - RoomDepth float64 // Room depth in meters - Zones []Zone // Zones to render - Nodes []Node // Nodes to render - People []Person // People to render - Portals []Portal // Portals to render - EventType NotificationType // Event type for title overlay - EventTitle string // Event title text - BackgroundColor color.Color // Background color + Width int // Image width in pixels (default 300) + Height int // Image height in pixels (default 300) + RoomWidth float64 // Room width in meters + RoomDepth float64 // Room depth in meters + Zones []Zone // Zones to render + Nodes []Node // Nodes to render + People []Person // People to render + Portals []Portal // Portals to render + EventType NotificationType // Event type for title overlay + EventTitle string // Event title text + BackgroundColor color.Color // Background color } // DefaultRenderConfig returns a config with sensible defaults. func DefaultRenderConfig() RenderConfig { return RenderConfig{ - Width: 300, - Height: 300, - RoomWidth: 10.0, - RoomDepth: 10.0, + Width: 300, + Height: 300, + RoomWidth: 10.0, + RoomDepth: 10.0, BackgroundColor: color.RGBA{26, 26, 46, 255}, // Dark background } } @@ -136,8 +136,8 @@ func (r *Renderer) Render() ([]byte, error) { scale := math.Min(scaleX, scaleY) // Center the drawing - offsetX := margin + (drawWidth - r.config.RoomWidth*scale)/2 - offsetY := margin + (drawHeight - r.config.RoomDepth*scale)/2 + offsetX := margin + (drawWidth-r.config.RoomWidth*scale)/2 + offsetY := margin + (drawHeight-r.config.RoomDepth*scale)/2 // Draw zones for _, zone := range r.config.Zones { @@ -191,9 +191,9 @@ func (r *Renderer) drawZone(zone Zone, offsetX, offsetY, scale float64) { if zone.Highlight { // Brighter fill for highlighted zone r.dc.SetColor(color.RGBA{ - R: uint8(math.Min(255, float64(zoneColor.R) * 1.5)), - G: uint8(math.Min(255, float64(zoneColor.G) * 1.5)), - B: uint8(math.Min(255, float64(zoneColor.B) * 1.5)), + R: uint8(math.Min(255, float64(zoneColor.R)*1.5)), + G: uint8(math.Min(255, float64(zoneColor.G)*1.5)), + B: uint8(math.Min(255, float64(zoneColor.B)*1.5)), A: 150, // Higher opacity for highlight }) r.dc.DrawRectangle(x, y, w, h) diff --git a/mothership/internal/render/floorplan_test.go b/mothership/internal/render/floorplan_test.go index ab47fd1..f3783a1 100644 --- a/mothership/internal/render/floorplan_test.go +++ b/mothership/internal/render/floorplan_test.go @@ -13,8 +13,8 @@ import ( // TestRendererDimensions tests that the renderer produces images with correct dimensions. func TestRendererDimensions(t *testing.T) { tests := []struct { - name string - width int + name string + width int height int }{ {"default 300x300", 0, 0}, @@ -59,22 +59,22 @@ func TestRendererZones(t *testing.T) { config := DefaultRenderConfig() config.Zones = []Zone{ { - ID: "kitchen", - Name: "Kitchen", - X: 1.0, - Y: 1.0, - W: 3.0, - D: 2.0, - Color: "#4fc3f7", + ID: "kitchen", + Name: "Kitchen", + X: 1.0, + Y: 1.0, + W: 3.0, + D: 2.0, + Color: "#4fc3f7", }, { - ID: "living", - Name: "Living Room", - X: 5.0, - Y: 1.0, - W: 4.0, - D: 3.0, - Color: "#81c784", + ID: "living", + Name: "Living Room", + X: 5.0, + Y: 1.0, + W: 4.0, + D: 3.0, + Color: "#81c784", }, } @@ -131,19 +131,19 @@ func TestRendererPeople(t *testing.T) { config := DefaultRenderConfig() config.People = []Person{ { - Name: "Alice", - X: 2.5, - Y: 2.0, - Z: 1.0, - Color: "#4488ff", + Name: "Alice", + X: 2.5, + Y: 2.0, + Z: 1.0, + Color: "#4488ff", Confidence: 0.85, }, { - Name: "Bob", - X: 7.0, - Y: 2.5, - Z: 1.0, - Color: "#44ff88", + Name: "Bob", + X: 7.0, + Y: 2.5, + Z: 1.0, + Color: "#44ff88", Confidence: 0.60, }, } @@ -163,13 +163,13 @@ func TestRendererPeople(t *testing.T) { // TestRendererFallDetected tests that fall state is rendered correctly. func TestRendererFallDetected(t *testing.T) { person := Person{ - Name: "Alice", - X: 2.5, - Y: 2.0, - Z: 0.2, // Low Z indicates fall - Color: "#4488ff", + Name: "Alice", + X: 2.5, + Y: 2.0, + Z: 0.2, // Low Z indicates fall + Color: "#4488ff", Confidence: 0.85, - IsFall: true, + IsFall: true, } data, err := GenerateFallDetectedThumbnail(10.0, 10.0, []Zone{ @@ -188,11 +188,11 @@ func TestRendererFallDetected(t *testing.T) { // TestGenerateZoneEnterThumbnail tests the zone entry thumbnail generator. func TestGenerateZoneEnterThumbnail(t *testing.T) { person := Person{ - Name: "Alice", - X: 2.5, - Y: 2.0, - Z: 1.0, - Color: "#4488ff", + Name: "Alice", + X: 2.5, + Y: 2.0, + Z: 1.0, + Color: "#4488ff", Confidence: 0.85, } @@ -232,11 +232,11 @@ func TestGenerateAnomalyAlertThumbnail(t *testing.T) { // TestGenerateSleepSummaryThumbnail tests the sleep summary thumbnail generator. func TestGenerateSleepSummaryThumbnail(t *testing.T) { person := Person{ - Name: "Alice", - X: 2.5, - Y: 2.0, - Z: 0.5, // Low Z (sleeping) - Color: "#4488ff", + Name: "Alice", + X: 2.5, + Y: 2.0, + Z: 0.5, // Low Z (sleeping) + Color: "#4488ff", Confidence: 0.85, } @@ -313,22 +313,22 @@ func TestPixelColors(t *testing.T) { config := DefaultRenderConfig() config.Zones = []Zone{ { - ID: "kitchen", - Name: "Kitchen", - X: 2.0, // Positioned to be visible - Y: 2.0, - W: 3.0, - D: 2.0, - Color: "#4fc3f7", // Light blue + ID: "kitchen", + Name: "Kitchen", + X: 2.0, // Positioned to be visible + Y: 2.0, + W: 3.0, + D: 2.0, + Color: "#4fc3f7", // Light blue }, } config.People = []Person{ { - Name: "Alice", - X: 3.5, // Center of zone - Y: 3.0, - Z: 1.0, - Color: "#ff0000", // Red person + Name: "Alice", + X: 3.5, // Center of zone + Y: 3.0, + Z: 1.0, + Color: "#ff0000", // Red person Confidence: 0.8, }, } @@ -522,8 +522,8 @@ func TestZoneBoundariesAtCorrectCoordinates(t *testing.T) { tests := []testCase{ { - name: "kitchen interior", - zone: config.Zones[0], + name: "kitchen interior", + zone: config.Zones[0], testPointMeters: struct{ x, y float64 }{x: 3.5, y: 3.0}, // Center of kitchen description: "Zone should be visible with its color blended with background", check: func(t *testing.T, pixelX, pixelY int, r, g, b, a uint32) { @@ -546,8 +546,8 @@ func TestZoneBoundariesAtCorrectCoordinates(t *testing.T) { }, }, { - name: "kitchen top-left corner", - zone: config.Zones[0], + name: "kitchen top-left corner", + zone: config.Zones[0], testPointMeters: struct{ x, y float64 }{x: 2.0, y: 2.0}, description: "Zone corner - should have white outline or zone fill", check: func(t *testing.T, pixelX, pixelY int, r, g, b, a uint32) { @@ -567,8 +567,8 @@ func TestZoneBoundariesAtCorrectCoordinates(t *testing.T) { }, }, { - name: "living interior", - zone: config.Zones[1], + name: "living interior", + zone: config.Zones[1], testPointMeters: struct{ x, y float64 }{x: 7.5, y: 6.5}, // Center of living description: "Second zone should have its green color visible", check: func(t *testing.T, pixelX, pixelY int, r, g, b, a uint32) { @@ -589,8 +589,8 @@ func TestZoneBoundariesAtCorrectCoordinates(t *testing.T) { }, }, { - name: "background area", - zone: Zone{X: 0, Y: 0, W: 1, D: 1}, + name: "background area", + zone: Zone{X: 0, Y: 0, W: 1, D: 1}, testPointMeters: struct{ x, y float64 }{x: 0.5, y: 0.5}, // Before first zone description: "Pure background color", check: func(t *testing.T, pixelX, pixelY int, r, g, b, a uint32) { @@ -793,11 +793,11 @@ func TestRenderPerformance200ms(t *testing.T) { config.People = make([]Person, 10) // 10 people for stress test for i := range config.People { config.People[i] = Person{ - Name: fmt.Sprintf("Person%d", i), - X: float64(i) + 1.0, - Y: 2.0, - Z: 1.0, - Color: "#4488ff", + Name: fmt.Sprintf("Person%d", i), + X: float64(i) + 1.0, + Y: 2.0, + Z: 1.0, + Color: "#4488ff", Confidence: 0.7, } } @@ -815,4 +815,3 @@ func TestRenderPerformance200ms(t *testing.T) { _ = start t.Log("Performance test completed successfully") } - diff --git a/mothership/internal/replay/engine.go b/mothership/internal/replay/engine.go index 5aa754c..8a4a7bb 100644 --- a/mothership/internal/replay/engine.go +++ b/mothership/internal/replay/engine.go @@ -28,7 +28,7 @@ func NewEngine(buffer *recording.Buffer, broadcaster BlobBroadcaster) *Engine { buffer: buffer, blobBroadcaster: broadcaster, defaultParams: &TunableParams{ - DeltaRMSThreshold: float64Ptr(0.02), + DeltaRMSThreshold: float64Ptr(0.02), TauS: float64Ptr(30.0), FresnelDecay: float64Ptr(2.0), NSubcarriers: intPtr(16), @@ -225,12 +225,12 @@ func (p *TunableParams) clone() *TunableParams { return nil } return &TunableParams{ - DeltaRMSThreshold: float64PtrCopy(p.DeltaRMSThreshold), - TauS: float64PtrCopy(p.TauS), - FresnelDecay: float64PtrCopy(p.FresnelDecay), - NSubcarriers: intPtrCopy(p.NSubcarriers), - BreathingSensitivity: float64PtrCopy(p.BreathingSensitivity), - MinConfidence: float64PtrCopy(p.MinConfidence), + DeltaRMSThreshold: float64PtrCopy(p.DeltaRMSThreshold), + TauS: float64PtrCopy(p.TauS), + FresnelDecay: float64PtrCopy(p.FresnelDecay), + NSubcarriers: intPtrCopy(p.NSubcarriers), + BreathingSensitivity: float64PtrCopy(p.BreathingSensitivity), + MinConfidence: float64PtrCopy(p.MinConfidence), } } diff --git a/mothership/internal/replay/integration_test.go b/mothership/internal/replay/integration_test.go index 206973c..03ed5c2 100644 --- a/mothership/internal/replay/integration_test.go +++ b/mothership/internal/replay/integration_test.go @@ -466,7 +466,7 @@ func TestTimelineEventMarkers(t *testing.T) { // Simulate event markers at specific timestamps eventMarkers := []struct { timestamp time.Time - eventType string + eventType string }{ {time.Unix(0, baseTime+10*20_000_000), "zone_entry"}, {time.Unix(0, baseTime+30*20_000_000), "anomaly"}, @@ -555,12 +555,12 @@ func createTestCSIFrames(count int, baseTime int64) [][]byte { frame := make([]byte, 152) // 24-byte header + 128*2 I/Q // Set header fields - frame[0] = 0xAA // node MAC byte 0 - frame[6] = 0xBB // peer MAC byte 0 + frame[0] = 0xAA // node MAC byte 0 + frame[6] = 0xBB // peer MAC byte 0 binary.LittleEndian.PutUint64(frame[12:20], uint64(i)) // timestamp - frame[20] = 206 // RSSI: -50 as unsigned byte (two's complement) - frame[22] = 6 // channel - frame[23] = 64 // nSub + frame[20] = 206 // RSSI: -50 as unsigned byte (two's complement) + frame[22] = 6 // channel + frame[23] = 64 // nSub // Set I/Q data to simulate motion (64 subcarriers = 128 bytes of I/Q) for j := 0; j < 64; j++ { @@ -649,8 +649,8 @@ func (m *mockSettingsHandler) Update(updates map[string]interface{}) error { } type mockReplayHandler struct { - settings *mockSettingsHandler - session *ReplaySession + settings *mockSettingsHandler + session *ReplaySession } func (m *mockReplayHandler) applyToLive(session *ReplaySession) error { diff --git a/mothership/internal/replay/pipeline.go b/mothership/internal/replay/pipeline.go index 6a1f401..69956ff 100644 --- a/mothership/internal/replay/pipeline.go +++ b/mothership/internal/replay/pipeline.go @@ -11,12 +11,12 @@ import ( // Pipeline processes CSI frames through the signal processing pipeline // during replay, producing blob updates that are broadcast to the dashboard. type Pipeline struct { - mu sync.Mutex - params *TunableParams + mu sync.Mutex + params *TunableParams broadcaster BlobBroadcaster - speed float64 - stopCh chan struct{} - + speed float64 + stopCh chan struct{} + // Blob state for tracking blobIDCounter int blobStates map[int]*blobState @@ -41,7 +41,7 @@ type blobState struct { func NewPipeline(params *TunableParams, broadcaster BlobBroadcaster) *Pipeline { return &Pipeline{ params: params, - broadcaster: broadcaster, + broadcaster: broadcaster, speed: 1.0, stopCh: make(chan struct{}), blobIDCounter: 1, @@ -101,7 +101,7 @@ func (p *Pipeline) generateDemoBlobs(timestampNS int64) []BlobUpdate { // Use timestamp to generate smooth motion // 20 Hz = 50ms per frame, so timestampNS / 50_000_000 gives us a frame counter frame := float64(timestampNS) / 50_000_000 - + // Generate 1-2 blobs moving in a figure-8 pattern blobs := make([]BlobUpdate, 0, 2) @@ -157,7 +157,7 @@ func (p *Pipeline) getTrail(blobID int, x, z float64) []float64 { // Add current position to trail state.trail = append(state.trail, x, z) - + // Keep trail at max length if len(state.trail) > 60 { state.trail = state.trail[len(state.trail)-60:] diff --git a/mothership/internal/replay/pipeline_test.go b/mothership/internal/replay/pipeline_test.go index 94bd518..5305d14 100644 --- a/mothership/internal/replay/pipeline_test.go +++ b/mothership/internal/replay/pipeline_test.go @@ -24,7 +24,7 @@ func (m *mockBroadcasterForPipeline) BroadcastReplayBlobs(blobs []BlobUpdate, ti func TestNewPipeline(t *testing.T) { params := &TunableParams{ DeltaRMSThreshold: float64Ptr(0.02), - TauS: float64Ptr(30.0), + TauS: float64Ptr(30.0), } broadcaster := &mockBroadcasterForPipeline{} @@ -200,7 +200,7 @@ func TestFloat64Helpers(t *testing.T) { x float64 want float64 }{ - {0, 1}, // cos(0) = 1 + {0, 1}, // cos(0) = 1 {3.14159265359, -1}, // cos(π) ≈ -1 } @@ -213,4 +213,3 @@ func TestFloat64Helpers(t *testing.T) { } } } - diff --git a/mothership/internal/replay/store.go b/mothership/internal/replay/store.go index 19af803..1d05eb1 100644 --- a/mothership/internal/replay/store.go +++ b/mothership/internal/replay/store.go @@ -37,12 +37,12 @@ const ( // RecordingStore is a disk-backed circular buffer for raw CSI frames. // It is safe for concurrent use. type RecordingStore struct { - mu sync.Mutex - f *os.File - fileSize int64 // total file size including header - writePos int64 // absolute file offset of next write + mu sync.Mutex + f *os.File + fileSize int64 // total file size including header + writePos int64 // absolute file offset of next write oldestPos int64 // absolute file offset of oldest valid record (0 = empty) - wrapPos int64 // writePos at time of last wrap (0 = no pending wrap) + wrapPos int64 // writePos at time of last wrap (0 = no pending wrap) } // NewRecordingStore opens or creates a recording store at path. diff --git a/mothership/internal/replay/types.go b/mothership/internal/replay/types.go index 19cb7ed..2bac5a7 100644 --- a/mothership/internal/replay/types.go +++ b/mothership/internal/replay/types.go @@ -14,19 +14,19 @@ import ( // Session represents a time-travel replay session. type Session struct { - mu sync.RWMutex - id string - fromMS int64 - toMS int64 - currentMS int64 - speed int - state SessionState - params *TunableParams - created_at int64 - updated_at int64 - ctx context.Context - cancel context.CancelFunc - stopCh chan struct{} + mu sync.RWMutex + id string + fromMS int64 + toMS int64 + currentMS int64 + speed int + state SessionState + params *TunableParams + created_at int64 + updated_at int64 + ctx context.Context + cancel context.CancelFunc + stopCh chan struct{} } // SessionState is the playback state of a session. @@ -40,13 +40,13 @@ const ( // TunableParams holds pipeline parameters that can be tuned during replay. type TunableParams struct { - DeltaRMSThreshold *float64 `json:"delta_rms_threshold,omitempty"` - TauS *float64 `json:"tau_s,omitempty"` - FresnelDecay *float64 `json:"fresnel_decay,omitempty"` - NSubcarriers *int `json:"n_subcarriers,omitempty"` - BreathingSensitivity *float64 `json:"breathing_sensitivity,omitempty"` - FresnelWeightSigma *float64 `json:"fresnel_weight_sigma,omitempty"` - MinConfidence *float64 `json:"min_confidence,omitempty"` + DeltaRMSThreshold *float64 `json:"delta_rms_threshold,omitempty"` + TauS *float64 `json:"tau_s,omitempty"` + FresnelDecay *float64 `json:"fresnel_decay,omitempty"` + NSubcarriers *int `json:"n_subcarriers,omitempty"` + BreathingSensitivity *float64 `json:"breathing_sensitivity,omitempty"` + FresnelWeightSigma *float64 `json:"fresnel_weight_sigma,omitempty"` + MinConfidence *float64 `json:"min_confidence,omitempty"` } // NewSession creates a new replay session. diff --git a/mothership/internal/replay/worker.go b/mothership/internal/replay/worker.go index e9de1da..926bff0 100644 --- a/mothership/internal/replay/worker.go +++ b/mothership/internal/replay/worker.go @@ -67,7 +67,6 @@ type FrameReader interface { // StoreStats is an alias for Stats for backward compatibility. type StoreStats = Stats - // NewWorker creates a new replay worker. func NewWorker(store FrameReader, processor *signal.ProcessorManager, broadcaster BlobBroadcaster) *Worker { return &Worker{ diff --git a/mothership/internal/shutdown/shutdown.go b/mothership/internal/shutdown/shutdown.go index f2eb983..985e368 100644 --- a/mothership/internal/shutdown/shutdown.go +++ b/mothership/internal/shutdown/shutdown.go @@ -43,7 +43,7 @@ type EventWriter interface { // Manager orchestrates the 10-step graceful shutdown sequence. type Manager struct { - baselineFlusher BaselineFlusher + baselineFlusher BaselineFlusher processorManager interface{} // *sigproc.ProcessorManager baselineStore interface{} // *sigproc.BaselineStore recordingSyncer RecordingSyncer diff --git a/mothership/internal/signal/ambient.go b/mothership/internal/signal/ambient.go index 39d64c6..0cc798b 100644 --- a/mothership/internal/signal/ambient.go +++ b/mothership/internal/signal/ambient.go @@ -9,18 +9,18 @@ import ( // Ambient confidence constants const ( - HealthWindow = 60 // Seconds of history for health metrics - HealthSampleRate = 20 // Expected samples per second at active rate (default) + HealthWindow = 60 // Seconds of history for health metrics + HealthSampleRate = 20 // Expected samples per second at active rate (default) HealthHistorySize = HealthWindow * HealthSampleRate PhaseStabilityWindow = 100 // Samples for phase stability calculation DriftWindow = 200 // Samples for drift calculation NoiseFloor = -95 // dBm - assumed noise floor for SNR calculation // Health score weights (per specification) - SNRWeight = 0.40 + SNRWeight = 0.40 PhaseStabilityWeight = 0.30 - PacketRateWeight = 0.20 - BaselineDriftWeight = 0.10 + PacketRateWeight = 0.20 + BaselineDriftWeight = 0.10 ) // LinkHealth holds per-link health metrics @@ -28,22 +28,22 @@ type LinkHealth struct { mu sync.RWMutex // Signal quality metrics (raw values) - SNR float64 // Signal-to-noise ratio estimate (raw) - PhaseStability float64 // Phase variance (radians²) - PacketRate float64 // Actual packet rate (Hz) - DriftRate float64 // Baseline drift rate (normalized 0-1) - PhaseVariance float64 // Current phase variance + SNR float64 // Signal-to-noise ratio estimate (raw) + PhaseStability float64 // Phase variance (radians²) + PacketRate float64 // Actual packet rate (Hz) + DriftRate float64 // Baseline drift rate (normalized 0-1) + PhaseVariance float64 // Current phase variance // Sub-scores (0-1 range, for dashboard breakdown) - SNRScore float64 + SNRScore float64 PhaseStabilityScore float64 - PacketRateScore float64 - DriftScore float64 + PacketRateScore float64 + DriftScore float64 // History buffers - rssiHistory []int8 - rssiWriteIdx int - rssiCount int + rssiHistory []int8 + rssiWriteIdx int + rssiCount int phaseVarHistory []float64 phaseVarWriteIdx int @@ -53,26 +53,26 @@ type LinkHealth struct { timestampWriteIdx int timestampCount int - baselineHistory [][]float64 // Snapshots for drift calculation - baselineWriteIdx int - baselineCount int + baselineHistory [][]float64 // Snapshots for drift calculation + baselineWriteIdx int + baselineCount int // Motion tracking for SNR estimation - deltaRMSHistory []float64 // Motion-period deltaRMS values - deltaRMSWriteIdx int - deltaRMSCount int - quietDeltaRMSHistory []float64 // Quiet-period deltaRMS for noise estimation + deltaRMSHistory []float64 // Motion-period deltaRMS values + deltaRMSWriteIdx int + deltaRMSCount int + quietDeltaRMSHistory []float64 // Quiet-period deltaRMS for noise estimation quietDeltaRMSWriteIdx int - quietDeltaRMSCount int + quietDeltaRMSCount int // Composite score ambientConfidence float64 lastUpdate time.Time // Tracking state - nSub int - linkID string - configuredRate float64 // Configured packet rate (Hz) + nSub int + linkID string + configuredRate float64 // Configured packet rate (Hz) } // NewLinkHealth creates a new link health monitor @@ -82,11 +82,11 @@ func NewLinkHealth(linkID string, nSub int) *LinkHealth { phaseVarHistory: make([]float64, PhaseStabilityWindow), timestampHistory: make([]time.Time, HealthHistorySize), baselineHistory: make([][]float64, DriftWindow), - deltaRMSHistory: make([]float64, 600), // 30s at 20Hz for motion periods + deltaRMSHistory: make([]float64, 600), // 30s at 20Hz for motion periods quietDeltaRMSHistory: make([]float64, 1200), // 60s at 20Hz for quiet periods nSub: nSub, linkID: linkID, - PhaseStability: 1.0, // Assume unstable until proven otherwise + PhaseStability: 1.0, // Assume unstable until proven otherwise configuredRate: float64(HealthSampleRate), // Default to 20 Hz } } @@ -440,10 +440,10 @@ func (lh *LinkHealth) GetHealthMetrics() (snr, phaseStability, packetRate, drift // HealthDetails represents the detailed health scores for API response type HealthDetails struct { - SNR float64 `json:"snr"` + SNR float64 `json:"snr"` PhaseStability float64 `json:"phase_stability"` - PacketRate float64 `json:"packet_rate"` - BaselineDrift float64 `json:"baseline_drift"` + PacketRate float64 `json:"packet_rate"` + BaselineDrift float64 `json:"baseline_drift"` } // GetHealthDetails returns the sub-scores for dashboard breakdown @@ -451,10 +451,10 @@ func (lh *LinkHealth) GetHealthDetails() HealthDetails { lh.mu.RLock() defer lh.mu.RUnlock() return HealthDetails{ - SNR: lh.SNRScore, + SNR: lh.SNRScore, PhaseStability: lh.PhaseStabilityScore, - PacketRate: lh.PacketRateScore, - BaselineDrift: lh.DriftScore, + PacketRate: lh.PacketRateScore, + BaselineDrift: lh.DriftScore, } } @@ -465,7 +465,6 @@ func (lh *LinkHealth) GetAmbientConfidence() float64 { return lh.ambientConfidence } - // GetDeltaRMSVariance returns the variance of deltaRMS values during motion periods // This is used for periodic interference detection (diagnostic Rule 5) func (lh *LinkHealth) GetDeltaRMSVariance() float64 { diff --git a/mothership/internal/signal/ambient_test.go b/mothership/internal/signal/ambient_test.go index 479cbbd..2b394fc 100644 --- a/mothership/internal/signal/ambient_test.go +++ b/mothership/internal/signal/ambient_test.go @@ -66,9 +66,9 @@ func TestLinkHealth_SNRScoreMapping(t *testing.T) { wantMin float64 wantMax float64 }{ - {"SNR=1 (ratio=1)", 1.0, 0.0, 0.3}, // Low SNR, low score - {"SNR=10 (ratio=10)", 10.0, 0.4, 0.7}, // Medium SNR, medium score - {"SNR=100 (ratio=100)", 100.0, 0.9, 1.01}, // High SNR, high score + {"SNR=1 (ratio=1)", 1.0, 0.0, 0.3}, // Low SNR, low score + {"SNR=10 (ratio=10)", 10.0, 0.4, 0.7}, // Medium SNR, medium score + {"SNR=100 (ratio=100)", 100.0, 0.9, 1.01}, // High SNR, high score {"SNR=1000 (ratio=1000)", 1000.0, 0.9, 1.01}, // capped at 1.0 } @@ -356,7 +356,7 @@ func TestLinkHealth_ClampToRange(t *testing.T) { // Test clamping with extreme values lh.mu.Lock() - lh.SNRScore = 2.0 // Above 1.0 + lh.SNRScore = 2.0 // Above 1.0 lh.PhaseStabilityScore = -0.5 // Below 0 lh.PacketRateScore = 0.5 lh.DriftScore = 0.5 diff --git a/mothership/internal/signal/baseline.go b/mothership/internal/signal/baseline.go index b778f3a..a99c007 100644 --- a/mothership/internal/signal/baseline.go +++ b/mothership/internal/signal/baseline.go @@ -7,21 +7,21 @@ import ( // Baseline configuration constants const ( - DefaultBaselineTimeConstant = 30.0 // seconds - DefaultMotionThreshold = 0.05 // deltaRMS threshold for motion gating - DefaultAlpha = 0.0033 // dt / (tau + dt) for dt=0.1s, tau=30s - BaselineConfidenceMin = 0.3 // Minimum confidence for stale baselines - BaselineStaleDays = 7 // Days before baseline is considered stale + DefaultBaselineTimeConstant = 30.0 // seconds + DefaultMotionThreshold = 0.05 // deltaRMS threshold for motion gating + DefaultAlpha = 0.0033 // dt / (tau + dt) for dt=0.1s, tau=30s + BaselineConfidenceMin = 0.3 // Minimum confidence for stale baselines + BaselineStaleDays = 7 // Days before baseline is considered stale ) // BaselineState holds the EMA baseline for a single link type BaselineState struct { - mu sync.RWMutex - Values []float64 // Per-subcarrier baseline amplitude - Initialized bool // True if baseline has been set - SampleCount int // Number of samples used to train baseline - LastUpdate time.Time // Time of last baseline update - Confidence float64 // 0-1 confidence in the baseline + mu sync.RWMutex + Values []float64 // Per-subcarrier baseline amplitude + Initialized bool // True if baseline has been set + SampleCount int // Number of samples used to train baseline + LastUpdate time.Time // Time of last baseline update + Confidence float64 // 0-1 confidence in the baseline } // NewBaselineState creates a new baseline state @@ -159,9 +159,9 @@ func (b *BaselineState) Reset() { // BaselineManager manages baselines for all links type BaselineManager struct { - mu sync.RWMutex + mu sync.RWMutex baselines map[string]*BaselineState // keyed by linkID (nodeMAC:peerMAC) - nSub int + nSub int } // NewBaselineManager creates a new baseline manager diff --git a/mothership/internal/signal/breathing.go b/mothership/internal/signal/breathing.go index 2233fa2..45556e4 100644 --- a/mothership/internal/signal/breathing.go +++ b/mothership/internal/signal/breathing.go @@ -9,30 +9,30 @@ import ( // Breathing detection constants const ( - BreathingSampleRate = 20.0 // Hz (active rate) - BreathingMinHz = 0.1 // Lower bound of breathing band - BreathingMaxHz = 0.5 // Upper bound of breathing band - BreathingRMSWindow = 1200 // 60 seconds at 20Hz - BreathingThreshold = 0.005 // Radians - detection threshold - BreathingSustainTime = 30 // Seconds sustained before detection - BreathingFFTSize = 512 // FFT window size (25.6s at 20Hz) - BreathingFFTZeroPad = 1024 // Zero-padded size for resolution - BreathingMotionThreshold = 0.03 // smoothDeltaRMS below which breathing is computed - BreathingEMAlpha = 0.01 // EMA smoothing for breathing rate - BreathingHealthThreshold = 0.7 // Minimum link health for breathing detection + BreathingSampleRate = 20.0 // Hz (active rate) + BreathingMinHz = 0.1 // Lower bound of breathing band + BreathingMaxHz = 0.5 // Upper bound of breathing band + BreathingRMSWindow = 1200 // 60 seconds at 20Hz + BreathingThreshold = 0.005 // Radians - detection threshold + BreathingSustainTime = 30 // Seconds sustained before detection + BreathingFFTSize = 512 // FFT window size (25.6s at 20Hz) + BreathingFFTZeroPad = 1024 // Zero-padded size for resolution + BreathingMotionThreshold = 0.03 // smoothDeltaRMS below which breathing is computed + BreathingEMAlpha = 0.01 // EMA smoothing for breathing rate + BreathingHealthThreshold = 0.7 // Minimum link health for breathing detection // FFT-based breathing detection constants (Phase 6) - FFTBreathingBufferSize = 60 // 30 seconds at 2Hz adaptive rate - FFTMinBreathingHz = 0.2 // Lower bound of breathing band (FFT) - FFTMaxBreathingHz = 1.0 // Upper bound of breathing band (FFT) - double breathing rate - FFTSNRThreshold = 15.0 // Minimum SNR in dB to declare breathing - FFTSampleRateHz = 2.0 // Adaptive sensing rate for breathing buffer - FFTMinSamples = 30 // Minimum 15s of data before detection can fire + FFTBreathingBufferSize = 60 // 30 seconds at 2Hz adaptive rate + FFTMinBreathingHz = 0.2 // Lower bound of breathing band (FFT) + FFTMaxBreathingHz = 1.0 // Upper bound of breathing band (FFT) - double breathing rate + FFTSNRThreshold = 15.0 // Minimum SNR in dB to declare breathing + FFTSampleRateHz = 2.0 // Adaptive sensing rate for breathing buffer + FFTMinSamples = 30 // Minimum 15s of data before detection can fire // Dwell tracker timeouts (Phase 6) - DwellMotionToPossiblyTime = 500 // ms - debounce before transitioning to POSSIBLY_PRESENT - DwellPossiblyToClearTime = 60000 // ms - 60s without motion/breathing -> CLEAR - DwellStationaryToClearTime = 120000 // ms - 120s without breathing -> CLEAR + DwellMotionToPossiblyTime = 500 // ms - debounce before transitioning to POSSIBLY_PRESENT + DwellPossiblyToClearTime = 60000 // ms - 60s without motion/breathing -> CLEAR + DwellStationaryToClearTime = 120000 // ms - 120s without breathing -> CLEAR ) // BiquadCoeffs holds coefficients for one biquad section @@ -385,11 +385,11 @@ func (bd *BreathingDetector) IsHealthGated() bool { // FFTBreathingResult holds the result of FFT-based breathing detection type FFTBreathingResult struct { - IsBreathing bool // True if breathing detected - FrequencyHz float64 // Peak frequency in Hz (0.2-1.0 Hz band) - Confidence float64 // Detection confidence (0-1) - PeakSNRdB float64 // Peak-to-median SNR in dB - BreathingBPM float64 // Estimated breathing rate in breaths per minute + IsBreathing bool // True if breathing detected + FrequencyHz float64 // Peak frequency in Hz (0.2-1.0 Hz band) + Confidence float64 // Detection confidence (0-1) + PeakSNRdB float64 // Peak-to-median SNR in dB + BreathingBPM float64 // Estimated breathing rate in breaths per minute } // FFTBreathingDetector detects stationary persons via FFT analysis of deltaRMS samples @@ -586,11 +586,11 @@ func (bd *FFTBreathingDetector) Detect() FFTBreathingResult { confidence := math.Min(1.0, math.Max(0.0, (snrDb-bd.snrThreshold)/10.0)) bd.lastResult = FFTBreathingResult{ - IsBreathing: isBreathing, - FrequencyHz: freqHz, - Confidence: confidence, - PeakSNRdB: snrDb, - BreathingBPM: breathingBPM, + IsBreathing: isBreathing, + FrequencyHz: freqHz, + Confidence: confidence, + PeakSNRdB: snrDb, + BreathingBPM: breathingBPM, } return bd.lastResult @@ -682,10 +682,10 @@ func (s DwellState) MarshalText() ([]byte, error) { type DwellTracker struct { mu sync.RWMutex - state DwellState - lastMotionTime time.Time // When motion was last detected - lastBreathTime time.Time // When breathing was last detected - stateChangeTime time.Time // When we entered the current state + state DwellState + lastMotionTime time.Time // When motion was last detected + lastBreathTime time.Time // When breathing was last detected + stateChangeTime time.Time // When we entered the current state motionDebounceStart time.Time // When current quiescence started // FFT-based breathing detector diff --git a/mothership/internal/signal/breathing_noise_test.go b/mothership/internal/signal/breathing_noise_test.go index 2b82dda..68317d8 100644 --- a/mothership/internal/signal/breathing_noise_test.go +++ b/mothership/internal/signal/breathing_noise_test.go @@ -20,19 +20,19 @@ func TestFFTBreathingDetector_NoDetectionWithNoise(t *testing.T) { for i := 0; i < FFTBreathingBufferSize; i++ { noise := (rand.Float64() - 0.5) * 0.001 bd.AddSample(noise) - } + } - result := bd.Detect() - if result.IsBreathing { - falsePositives++ - } - } + result := bd.Detect() + if result.IsBreathing { + falsePositives++ + } + } falsePositiveRate := float64(falsePositives) / float64(trials) - t.Logf("False positive rate: %.1f%% (target < 5%%)", falsePositiveRate*100) + t.Logf("False positive rate: %.1f%% (target < 5%%)", falsePositiveRate*100) - // Allow up to 5% false positive rate - if falsePositiveRate > 0.05 { - t.Errorf("False positive rate = %.1f%%, want < 5%%", falsePositiveRate*100) - } + // Allow up to 5% false positive rate + if falsePositiveRate > 0.05 { + t.Errorf("False positive rate = %.1f%%, want < 5%%", falsePositiveRate*100) + } } diff --git a/mothership/internal/signal/breathing_test.go b/mothership/internal/signal/breathing_test.go index 1bd3146..922f167 100644 --- a/mothership/internal/signal/breathing_test.go +++ b/mothership/internal/signal/breathing_test.go @@ -548,7 +548,6 @@ func TestFFTBreathingDetector_Detect_SyntheticBreathing(t *testing.T) { result.FrequencyHz, result.PeakSNRdB, result.BreathingBPM) } - func TestFFTBreathingDetector_OutsideBandFrequency(t *testing.T) { // Test that the FFT breathing detector doesn't produce false positives with // signals outside the breathing band. Sub-band signals cause spectral leakage diff --git a/mothership/internal/signal/diurnal.go b/mothership/internal/signal/diurnal.go index 768c5c5..b574185 100644 --- a/mothership/internal/signal/diurnal.go +++ b/mothership/internal/signal/diurnal.go @@ -9,7 +9,7 @@ import ( // Diurnal configuration constants const ( - DiurnalSlots = 24 // One slot per hour + DiurnalSlots = 24 // One slot per hour DiurnalMinSamples = 300 // Minimum samples per slot (spec requirement: >= 300 samples/slot to mark ready) DiurnalLearningDays = 7 // Days before diurnal baseline activates diff --git a/mothership/internal/signal/diurnal_test.go b/mothership/internal/signal/diurnal_test.go index cbc5974..0154203 100644 --- a/mothership/internal/signal/diurnal_test.go +++ b/mothership/internal/signal/diurnal_test.go @@ -925,7 +925,7 @@ func TestDiurnalBaseline_Crossfade15MinuteWindow(t *testing.T) { // Test progression across the first 15 minutes of hour 13 testMinutes := []int{0, 5, 10, 15, 16, 30, 45} // expectedFracs: 0 at start, 1/3 at 5 min, 2/3 at 10 min, 1 at 15 min, then 1 for rest - expectedFracs := []float64{0.0, 1.0/3.0, 2.0/3.0, 1.0, 1.0, 1.0, 1.0} + expectedFracs := []float64{0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0, 1.0, 1.0, 1.0} for i, minute := range testMinutes { testTime := time.Date(2024, 1, 15, 13, minute, 0, 0, loc) diff --git a/mothership/internal/signal/features.go b/mothership/internal/signal/features.go index 767c9e8..00d575b 100644 --- a/mothership/internal/signal/features.go +++ b/mothership/internal/signal/features.go @@ -6,13 +6,13 @@ import ( // Feature extraction constants const ( - DefaultDeltaRMSThreshold = 0.02 // Motion detection threshold - NBVIWindowSize = 100 // Samples for NBVI calculation (~5s at 20Hz) - NBVIUpdateInterval = 40 // Recalculate every 40 samples - NBVIMinSamples = 50 // Minimum samples before selection - NBVITopCount = 16 // Select top 16 subcarriers - NBVIMinThreshold = 0.001 // Minimum NBVI to be considered - DeltaRMSSmoothingAlpha = 0.3 // Exponential smoothing for deltaRMS + DefaultDeltaRMSThreshold = 0.02 // Motion detection threshold + NBVIWindowSize = 100 // Samples for NBVI calculation (~5s at 20Hz) + NBVIUpdateInterval = 40 // Recalculate every 40 samples + NBVIMinSamples = 50 // Minimum samples before selection + NBVITopCount = 16 // Select top 16 subcarriers + NBVIMinThreshold = 0.001 // Minimum NBVI to be considered + DeltaRMSSmoothingAlpha = 0.3 // Exponential smoothing for deltaRMS ) // NBVITracker tracks NBVI statistics for subcarrier selection @@ -161,7 +161,7 @@ func (n *NBVITracker) SampleCount() int { // MotionDetector computes motion features from processed CSI type MotionDetector struct { - nbviTracker *NBVITracker + nbviTracker *NBVITracker smoothDeltaRMS float64 lastDeltaRMS float64 motionDetected bool @@ -178,12 +178,12 @@ func NewMotionDetector(nSub int) *MotionDetector { // MotionFeatures holds extracted motion features type MotionFeatures struct { - DeltaRMS float64 // Raw delta RMS - SmoothDeltaRMS float64 // Smoothed delta RMS - MotionDetected bool // True if motion above threshold - PhaseVariance float64 // Phase variance over selected subcarriers - SelectedCount int // Number of selected subcarriers - PhaseStd float64 // Phase standard deviation + DeltaRMS float64 // Raw delta RMS + SmoothDeltaRMS float64 // Smoothed delta RMS + MotionDetected bool // True if motion above threshold + PhaseVariance float64 // Phase variance over selected subcarriers + SelectedCount int // Number of selected subcarriers + PhaseStd float64 // Phase standard deviation } // Process processes a new CSI frame and extracts motion features diff --git a/mothership/internal/signal/persist.go b/mothership/internal/signal/persist.go index 955e8e1..fa9c6eb 100644 --- a/mothership/internal/signal/persist.go +++ b/mothership/internal/signal/persist.go @@ -312,8 +312,8 @@ func (s *BaselineStore) LoadDiurnal(linkID string, nSub int) (*DiurnalSnapshot, } snapshot := &DiurnalSnapshot{ - LinkID: linkID, - Created: time.Unix(createdAt, 0), + LinkID: linkID, + Created: time.Unix(createdAt, 0), } // Initialize slots diff --git a/mothership/internal/signal/phase.go b/mothership/internal/signal/phase.go index abb15e9..425aac7 100644 --- a/mothership/internal/signal/phase.go +++ b/mothership/internal/signal/phase.go @@ -19,9 +19,9 @@ const ( // NullSubcarriers are subcarrier indices that are null/guard var NullSubcarriers = map[int]bool{ - 0: true, // DC - 1: true, // guard - 63: true, // guard + 0: true, // DC + 1: true, // guard + 63: true, // guard } // GuardBandSubcarriers are the center guard band indices diff --git a/mothership/internal/signal/phase_property_test.go b/mothership/internal/signal/phase_property_test.go index 6da5350..08f3c10 100644 --- a/mothership/internal/signal/phase_property_test.go +++ b/mothership/internal/signal/phase_property_test.go @@ -82,8 +82,8 @@ func TestPhaseSanitizeProperty(t *testing.T) { payloadGen: func(nSub int) []int8 { payload := make([]int8, nSub*2) for i := 0; i < nSub*2; i += 2 { - payload[i] = 127 // I - payload[i+1] = 0 // Q + payload[i] = 127 // I + payload[i+1] = 0 // Q } return payload }, @@ -96,8 +96,8 @@ func TestPhaseSanitizeProperty(t *testing.T) { payloadGen: func(nSub int) []int8 { payload := make([]int8, nSub*2) for i := 0; i < nSub*2; i += 2 { - payload[i] = 0 // I - payload[i+1] = 127 // Q + payload[i] = 0 // I + payload[i+1] = 127 // Q } return payload }, @@ -318,27 +318,27 @@ func TestPhaseSanitizeInvalidInputs(t *testing.T) { errContains string }{ { - name: "payload too short", - payload: []int8{1, 2, 3}, - rssiDBm: -50, - nSub: 64, - wantErr: true, + name: "payload too short", + payload: []int8{1, 2, 3}, + rssiDBm: -50, + nSub: 64, + wantErr: true, errContains: "too short", }, { - name: "zero subcarriers", - payload: []int8{}, - rssiDBm: -50, - nSub: 0, - wantErr: true, + name: "zero subcarriers", + payload: []int8{}, + rssiDBm: -50, + nSub: 0, + wantErr: true, errContains: "zero subcarriers", }, { - name: "nSub larger than payload", - payload: []int8{1, 2, 3, 4}, - rssiDBm: -50, - nSub: 10, - wantErr: true, + name: "nSub larger than payload", + payload: []int8{1, 2, 3, 4}, + rssiDBm: -50, + nSub: 10, + wantErr: true, errContains: "too short", }, } diff --git a/mothership/internal/signal/processor.go b/mothership/internal/signal/processor.go index 1f31aa8..47a8a92 100644 --- a/mothership/internal/signal/processor.go +++ b/mothership/internal/signal/processor.go @@ -46,8 +46,8 @@ type ProcessResult struct { DiurnalWeight float64 // Weight of diurnal in baseline (0-1) DiurnalReady bool // True if diurnal slot has enough samples // Phase 6: Dwell state for stationary person detection - DwellState DwellState // Current dwell state (CLEAR, MOTION_DETECTED, etc.) - BreathingBPM float64 // Estimated breathing rate in BPM + DwellState DwellState // Current dwell state (CLEAR, MOTION_DETECTED, etc.) + BreathingBPM float64 // Estimated breathing rate in BPM } // Process processes a raw CSI frame and returns processed data with features @@ -324,9 +324,9 @@ type LinkMotionState struct { AmbientConfidence float64 DiurnalConfidence float64 // Phase 6: Dwell state for stationary person detection - DwellState DwellState // CLEAR, MOTION_DETECTED, POSSIBLY_PRESENT, STATIONARY_DETECTED - BreathingBPM float64 // Estimated breathing rate from dwell tracker - StationaryDetected bool // True if in STATIONARY_DETECTED state + DwellState DwellState // CLEAR, MOTION_DETECTED, POSSIBLY_PRESENT, STATIONARY_DETECTED + BreathingBPM float64 // Estimated breathing rate from dwell tracker + StationaryDetected bool // True if in STATIONARY_DETECTED state } // GetAllMotionStates returns motion states for all links @@ -339,16 +339,16 @@ func (pm *ProcessorManager) GetAllMotionStates() []LinkMotionState { processor.mu.RLock() dwellState := processor.dwellTracker.GetState() state := LinkMotionState{ - LinkID: linkID, - MotionDetected: processor.motionDetector.IsMotionDetected(), - SmoothDeltaRMS: processor.motionDetector.GetSmoothDeltaRMS(), - BaselineConf: processor.baseline.GetConfidence(), - BreathingDetected: processor.breathing.IsDetected(), - BreathingRate: processor.breathing.GetBreathingRate(), - AmbientConfidence: processor.health.GetAmbientConfidence(), - DiurnalConfidence: processor.diurnal.GetOverallConfidence(), - DwellState: dwellState, - BreathingBPM: processor.dwellTracker.GetBreathingRate(), + LinkID: linkID, + MotionDetected: processor.motionDetector.IsMotionDetected(), + SmoothDeltaRMS: processor.motionDetector.GetSmoothDeltaRMS(), + BaselineConf: processor.baseline.GetConfidence(), + BreathingDetected: processor.breathing.IsDetected(), + BreathingRate: processor.breathing.GetBreathingRate(), + AmbientConfidence: processor.health.GetAmbientConfidence(), + DiurnalConfidence: processor.diurnal.GetOverallConfidence(), + DwellState: dwellState, + BreathingBPM: processor.dwellTracker.GetBreathingRate(), StationaryDetected: dwellState == DwellStationaryDetected, } processor.mu.RUnlock() @@ -497,14 +497,14 @@ func (pm *ProcessorManager) GetStationaryPersonCount() int { // DiurnalLearningStatus represents the diurnal baseline learning state for a link type DiurnalLearningStatus struct { - LinkID string `json:"link_id"` - IsLearning bool `json:"is_learning"` - DaysRemaining float64 `json:"days_remaining"` - Progress float64 `json:"progress"` // 0-100 percentage - IsReady bool `json:"is_ready"` - SlotsReady int `json:"slots_ready"` // Number of slots with >= 100 samples - DiurnalConfidence float64 `json:"diurnal_confidence"` - CreatedAt time.Time `json:"created_at"` + LinkID string `json:"link_id"` + IsLearning bool `json:"is_learning"` + DaysRemaining float64 `json:"days_remaining"` + Progress float64 `json:"progress"` // 0-100 percentage + IsReady bool `json:"is_ready"` + SlotsReady int `json:"slots_ready"` // Number of slots with >= 100 samples + DiurnalConfidence float64 `json:"diurnal_confidence"` + CreatedAt time.Time `json:"created_at"` } // GetDiurnalLearningStatus returns diurnal learning status for all links @@ -585,10 +585,10 @@ func (pm *ProcessorManager) CheckDiurnalReadinessTransitions(previouslyReady map // TrackedBlob represents a tracked spatial blob from the fusion engine. type TrackedBlob struct { - ID int - X, Y, Z float64 + ID int + X, Y, Z float64 VX, VY, VZ float64 - Weight float64 + Weight float64 // Identity fields (populated by BLE-to-blob matching) PersonID string `json:"person_id,omitempty"` PersonLabel string `json:"person_label,omitempty"` diff --git a/mothership/internal/simulator/accuracy.go b/mothership/internal/simulator/accuracy.go index 663d1c0..e56950f 100644 --- a/mothership/internal/simulator/accuracy.go +++ b/mothership/internal/simulator/accuracy.go @@ -17,23 +17,23 @@ func NewAccuracyEstimator() *AccuracyEstimator { // AccuracyReport contains accuracy metrics from a simulation run. type AccuracyReport struct { - MedianError float64 `json:"median_error_m"` // Median position error in meters - MeanError float64 `json:"mean_error_m"` // Mean position error in meters - MaxError float64 `json:"max_error_m"` // Maximum position error in meters - P95Error float64 `json:"p95_error_m"` // 95th percentile error - DetectionRate float64 `json:"detection_rate"` // Fraction of walkers detected - FalsePositiveRate float64 `json:"false_positive_rate"` // False positives per second - RecallAt1m float64 `json:"recall_at_1m"` // Fraction within 1m of true position - RecallAt2m float64 `json:"recall_at_2m"` // Fraction within 2m of true position - SampleCount int `json:"sample_count"` // Number of walker positions evaluated + MedianError float64 `json:"median_error_m"` // Median position error in meters + MeanError float64 `json:"mean_error_m"` // Mean position error in meters + MaxError float64 `json:"max_error_m"` // Maximum position error in meters + P95Error float64 `json:"p95_error_m"` // 95th percentile error + DetectionRate float64 `json:"detection_rate"` // Fraction of walkers detected + FalsePositiveRate float64 `json:"false_positive_rate"` // False positives per second + RecallAt1m float64 `json:"recall_at_1m"` // Fraction within 1m of true position + RecallAt2m float64 `json:"recall_at_2m"` // Fraction within 2m of true position + SampleCount int `json:"sample_count"` // Number of walker positions evaluated } // Recommendation is a deployment recommendation. type Recommendation struct { - Priority string `json:"priority"` // "high", "medium", "low" - Message string `json:"message"` // Human-readable recommendation - Impact float64 `json:"impact"` // Estimated improvement (0-1) - Position *Point `json:"position,omitempty"` // Suggested position (if applicable) + Priority string `json:"priority"` // "high", "medium", "low" + Message string `json:"message"` // Human-readable recommendation + Impact float64 `json:"impact"` // Estimated improvement (0-1) + Position *Point `json:"position,omitempty"` // Suggested position (if applicable) } // RecommendationEngine generates deployment recommendations. @@ -79,11 +79,11 @@ func (ae *AccuracyEstimator) Compute(walkers []*SimWalker, blobs []BlobResult) A if len(errors) == 0 { return AccuracyReport{ - MedianError: math.Inf(1), - MeanError: math.Inf(1), - MaxError: math.Inf(1), - DetectionRate: 0, - SampleCount: len(truePositions), + MedianError: math.Inf(1), + MeanError: math.Inf(1), + MaxError: math.Inf(1), + DetectionRate: 0, + SampleCount: len(truePositions), } } @@ -299,17 +299,17 @@ func (re *RecommendationEngine) Generate(space *Space, nodes *NodeSet, gdopMap [ // ShoppingList contains hardware recommendations. type ShoppingList struct { - MinimumNodes int `json:"minimum_nodes"` - RecommendedNodes int `json:"recommended_nodes"` - ExpectedAccuracy float64 `json:"expected_accuracy_m"` - CoveragePercent float64 `json:"coverage_percent"` - HardwareList []string `json:"hardware_list"` - AmazonSearchURL string `json:"amazon_search_url"` - OptimalPositions []Point `json:"optimal_positions,omitempty"` - CoverageGaps []Point `json:"coverage_gaps,omitempty"` // Positions with poor coverage - RecommendedAdditions []NodeAddition `json:"recommended_additions,omitempty"` // Specific nodes to add - EstimatedCost float64 `json:"estimated_cost_usd,omitempty"` // Estimated hardware cost in USD - SpaceDimensions SpaceDimensions `json:"space_dimensions"` // Space dimensions for reference + MinimumNodes int `json:"minimum_nodes"` + RecommendedNodes int `json:"recommended_nodes"` + ExpectedAccuracy float64 `json:"expected_accuracy_m"` + CoveragePercent float64 `json:"coverage_percent"` + HardwareList []string `json:"hardware_list"` + AmazonSearchURL string `json:"amazon_search_url"` + OptimalPositions []Point `json:"optimal_positions,omitempty"` + CoverageGaps []Point `json:"coverage_gaps,omitempty"` // Positions with poor coverage + RecommendedAdditions []NodeAddition `json:"recommended_additions,omitempty"` // Specific nodes to add + EstimatedCost float64 `json:"estimated_cost_usd,omitempty"` // Estimated hardware cost in USD + SpaceDimensions SpaceDimensions `json:"space_dimensions"` // Space dimensions for reference } // SpaceDimensions describes the space dimensions @@ -327,7 +327,7 @@ type NodeAddition struct { Name string `json:"name"` Position Point `json:"position"` Role string `json:"role"` - Height string `json:"height_description"` // e.g., "ceiling", "wall", "desk" + Height string `json:"height_description"` // e.g., "ceiling", "wall", "desk" Improvement float64 `json:"estimated_improvement"` // 0-1, estimated coverage improvement } @@ -387,24 +387,24 @@ func GenerateShoppingListFromResults(space *Space, nodes *NodeSet, coverageScore // Estimated cost (as of 2025) estimatedCost := float64(recNodes)*15.0 + // ESP32-S3 dev board - float64(recNodes)*8.0 + // Power supply - float64(recNodes)*3.0 + // USB cable - float64(recNodes)*2.0 // Cable clips + float64(recNodes)*8.0 + // Power supply + float64(recNodes)*3.0 + // USB cable + float64(recNodes)*2.0 // Cable clips // Amazon search URL (non-affiliate) searchURL := fmt.Sprintf("https://www.amazon.com/s?k=esp32-s3+devkit+usb-c+psram") return ShoppingList{ - MinimumNodes: minNodes, - RecommendedNodes: recNodes, - ExpectedAccuracy: expectedAccuracy, - CoveragePercent: coverageScore, - HardwareList: hardware, - AmazonSearchURL: searchURL, - OptimalPositions: optimalPositions, - CoverageGaps: coverageGaps, + MinimumNodes: minNodes, + RecommendedNodes: recNodes, + ExpectedAccuracy: expectedAccuracy, + CoveragePercent: coverageScore, + HardwareList: hardware, + AmazonSearchURL: searchURL, + OptimalPositions: optimalPositions, + CoverageGaps: coverageGaps, RecommendedAdditions: recommendedAdditions, - EstimatedCost: estimatedCost, + EstimatedCost: estimatedCost, SpaceDimensions: SpaceDimensions{ Width: width, Depth: depth, @@ -422,14 +422,14 @@ func generateOptimalPositions(space *Space, count int) []Point { // Strategy: place nodes at corners and mid-points, with mixed heights corners := []Point{ - {X: minX + 0.5, Y: minY + 0.5, Z: 2.2}, // Low corner, high - {X: maxX - 0.5, Y: minY + 0.5, Z: 2.2}, // Low corner, high - {X: minX + 0.5, Y: maxY - 0.5, Z: 2.2}, // Low corner, high - {X: maxX - 0.5, Y: maxY - 0.5, Z: 2.2}, // Low corner, high + {X: minX + 0.5, Y: minY + 0.5, Z: 2.2}, // Low corner, high + {X: maxX - 0.5, Y: minY + 0.5, Z: 2.2}, // Low corner, high + {X: minX + 0.5, Y: maxY - 0.5, Z: 2.2}, // Low corner, high + {X: maxX - 0.5, Y: maxY - 0.5, Z: 2.2}, // Low corner, high {X: (minX + maxX) / 2, Y: minY + 0.5, Z: 2.5}, // Mid wall, high {X: (minX + maxX) / 2, Y: maxY - 0.5, Z: 2.5}, // Mid wall, high - {X: minX + 0.5, Y: (minY + maxY) / 2, Z: 0.3}, // Mid wall, low - {X: maxX - 0.5, Y: (minY + maxY) / 2, Z: 0.3}, // Mid wall, low + {X: minX + 0.5, Y: (minY + maxY) / 2, Z: 0.3}, // Mid wall, low + {X: maxX - 0.5, Y: (minY + maxY) / 2, Z: 0.3}, // Mid wall, low } for i := 0; i < count; i++ { diff --git a/mothership/internal/simulator/engine.go b/mothership/internal/simulator/engine.go index 6e396c2..547149d 100644 --- a/mothership/internal/simulator/engine.go +++ b/mothership/internal/simulator/engine.go @@ -39,7 +39,7 @@ type SimWalker struct { Type WalkerType `json:"type"` Position Point `json:"position"` Velocity Point `json:"velocity"` - Path []Point `json:"path,omitempty"` // for path walks + Path []Point `json:"path,omitempty"` // for path walks PathIndex int `json:"path_index,omitempty"` // current position in path TargetZones []string `json:"target_zones,omitempty"` // for zone walks TrueHistory []Point `json:"true_history,omitempty"` // ground truth positions @@ -47,13 +47,13 @@ type SimWalker struct { // Grid is the 3D spatial grid for Fresnel accumulation. type Grid struct { - CellSize float64 `json:"cell_size"` // meters - OriginX float64 `json:"origin_x"` // meters - OriginY float64 `json:"origin_y"` // meters - OriginZ float64 `json:"origin_z"` // meters - WidthCells int `json:"width_cells"` // number of cells in X - DepthCells int `json:"depth_cells"` // number of cells in Y - HeightCells int `json:"height_cells"` // number of cells in Z + CellSize float64 `json:"cell_size"` // meters + OriginX float64 `json:"origin_x"` // meters + OriginY float64 `json:"origin_y"` // meters + OriginZ float64 `json:"origin_z"` // meters + WidthCells int `json:"width_cells"` // number of cells in X + DepthCells int `json:"depth_cells"` // number of cells in Y + HeightCells int `json:"height_cells"` // number of cells in Z Data []float64 `json:"data"` // flattened 3D array [z][x][y] } @@ -66,14 +66,14 @@ type ZoneInfo struct { // SimulationResult contains the results of a simulation run. type SimulationResult struct { - Timestamp int64 `json:"timestamp_ms"` - Blobs []BlobResult `json:"blobs"` - CoverageScore float64 `json:"coverage_score"` // 0-100 - GDOPMap []float64 `json:"gdop_map"` // flattened grid - GridDimensions []int `json:"grid_dimensions"` // [width_cells, depth_cells, height_cells] - Recommendations []Recommendation `json:"recommendations"` - Accuracy AccuracyReport `json:"accuracy"` - ShoppingList ShoppingList `json:"shopping_list"` + Timestamp int64 `json:"timestamp_ms"` + Blobs []BlobResult `json:"blobs"` + CoverageScore float64 `json:"coverage_score"` // 0-100 + GDOPMap []float64 `json:"gdop_map"` // flattened grid + GridDimensions []int `json:"grid_dimensions"` // [width_cells, depth_cells, height_cells] + Recommendations []Recommendation `json:"recommendations"` + Accuracy AccuracyReport `json:"accuracy"` + ShoppingList ShoppingList `json:"shopping_list"` } // BlobResult is a simulated detection result. @@ -89,12 +89,12 @@ type BlobResult struct { // NewEngine creates a new simulator engine. func NewEngine(space *Space) *Engine { return &Engine{ - space: space, - nodes: NewNodeSet(), - walkers: make([]*SimWalker, 0), - subscribers: make([]chan *SimulationResult, 0), - propagation: NewPropagationModel(space), - accuracy: NewAccuracyEstimator(), + space: space, + nodes: NewNodeSet(), + walkers: make([]*SimWalker, 0), + subscribers: make([]chan *SimulationResult, 0), + propagation: NewPropagationModel(space), + accuracy: NewAccuracyEstimator(), recommendations: NewRecommendationEngine(), } } diff --git a/mothership/internal/simulator/gdop.go b/mothership/internal/simulator/gdop.go index 3cedd6a..5a1f0d9 100644 --- a/mothership/internal/simulator/gdop.go +++ b/mothership/internal/simulator/gdop.go @@ -8,25 +8,25 @@ import ( // GDOPResult contains GDOP computation results for a single cell type GDOPResult struct { - X, Y, Z float64 // Cell center position - GDOP float64 // Computed GDOP value (Infinity = no coverage) - Quality string // "excellent", "good", "fair", "poor", "none" + X, Y, Z float64 // Cell center position + GDOP float64 // Computed GDOP value (Infinity = no coverage) + Quality string // "excellent", "good", "fair", "poor", "none" ContributingLinks []string // Link IDs that contributed to this cell } // GridConfig defines the GDOP computation grid type GridConfig struct { - CellSize float64 // Grid cell size in meters - MinX, MinY float64 // Grid origin - Width float64 // Grid width - Depth float64 // Grid depth + CellSize float64 // Grid cell size in meters + MinX, MinY float64 // Grid origin + Width float64 // Grid width + Depth float64 // Grid depth } // GDOPComputer computes Geometric Dilution of Precision for coverage analysis type GDOPComputer struct { - links []Link - config GridConfig - maxZone int // Maximum Fresnel zone to consider (default 3) + links []Link + config GridConfig + maxZone int // Maximum Fresnel zone to consider (default 3) } // NewGDOPComputer creates a new GDOP computer @@ -347,15 +347,15 @@ func GDOPColorMap(gdop float64) GDOPColor { // GDOPHeatmapData represents flattened GDOP data for frontend rendering type GDOPHeatmapData struct { - Width int `json:"width"` // Grid width (columns) - Depth int `json:"depth"` // Grid depth (rows) - CellSize float64 `json:"cell_size"` // Cell size in meters - OriginX float64 `json:"origin_x"` // Grid origin X - OriginY float64 `json:"origin_y"` // Grid origin Y - GDOPValues []float64 `json:"gdop_values"` // Flattened GDOP values (9999 = infinity) - Qualities []string `json:"qualities"` // Flattened quality strings - Colors [][]uint8 `json:"colors"` // Flattened RGB colors [width*depth*3] - AccuracyMap []float64 `json:"accuracy_map"` // Expected accuracy in meters per cell + Width int `json:"width"` // Grid width (columns) + Depth int `json:"depth"` // Grid depth (rows) + CellSize float64 `json:"cell_size"` // Cell size in meters + OriginX float64 `json:"origin_x"` // Grid origin X + OriginY float64 `json:"origin_y"` // Grid origin Y + GDOPValues []float64 `json:"gdop_values"` // Flattened GDOP values (9999 = infinity) + Qualities []string `json:"qualities"` // Flattened quality strings + Colors [][]uint8 `json:"colors"` // Flattened RGB colors [width*depth*3] + AccuracyMap []float64 `json:"accuracy_map"` // Expected accuracy in meters per cell } // ToHeatmapData converts GDOP results to a heatmap-friendly format diff --git a/mothership/internal/simulator/handler.go b/mothership/internal/simulator/handler.go index d3d2747..dcbfe6a 100644 --- a/mothership/internal/simulator/handler.go +++ b/mothership/internal/simulator/handler.go @@ -158,8 +158,8 @@ func (h *Handler) removeWalker(w http.ResponseWriter, r *http.Request) { // Runs one simulation tick and returns results. func (h *Handler) simulate(w http.ResponseWriter, r *http.Request) { var req struct { - DurationSec int `json:"duration_sec"` - TickRateHz int `json:"tick_rate_hz"` + DurationSec int `json:"duration_sec"` + TickRateHz int `json:"tick_rate_hz"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -187,7 +187,7 @@ func (h *Handler) getResults(w http.ResponseWriter, r *http.Request) { func (h *Handler) computeGDOP(w http.ResponseWriter, r *http.Request) { var req struct { Nodes []Node `json:"nodes"` - Space *Space `json:"space"` + Space *Space `json:"space"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -401,7 +401,7 @@ func (h *Handler) getGDOPHeatmap(w http.ResponseWriter, r *http.Request) { // Build response response := map[string]interface{}{ - "gdop_map": heatmapData.GDOPValues, + "gdop_map": heatmapData.GDOPValues, "grid_dimensions": []int{heatmapData.Width, heatmapData.Depth}, "grid_origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY}, "cell_size_m": heatmapData.CellSize, diff --git a/mothership/internal/simulator/node.go b/mothership/internal/simulator/node.go index c43bd93..5393bd8 100644 --- a/mothership/internal/simulator/node.go +++ b/mothership/internal/simulator/node.go @@ -11,20 +11,20 @@ import ( type NodeType string const ( - NodeTypeReal NodeType = "esp32" // Real ESP32-S3 node + NodeTypeReal NodeType = "esp32" // Real ESP32-S3 node NodeTypeVirtual NodeType = "virtual" // Simulated virtual node - NodeTypeAP NodeType = "ap" // Access point (passive radar TX) + NodeTypeAP NodeType = "ap" // Access point (passive radar TX) ) // NodeRole represents the operational role of a node type NodeRole string const ( - RoleTX NodeRole = "tx" // Transmit only - RoleRX NodeRole = "rx" // Receive only - RoleTXRX NodeRole = "tx_rx" // Both transmit and receive + RoleTX NodeRole = "tx" // Transmit only + RoleRX NodeRole = "rx" // Receive only + RoleTXRX NodeRole = "tx_rx" // Both transmit and receive RolePassive NodeRole = "passive" // Passive radar (RX only, AP as TX) - RoleIdle NodeRole = "idle" // Disabled + RoleIdle NodeRole = "idle" // Disabled ) // Node represents a virtual or real node in the simulation @@ -36,8 +36,8 @@ type Node struct { Position Point `json:"position"` // X, Y, Z in meters Enabled bool `json:"enabled"` // For AP nodes - APBSSID string `json:"ap_bssid,omitempty"` - APChannel int `json:"ap_channel,omitempty"` + APBSSID string `json:"ap_bssid,omitempty"` + APChannel int `json:"ap_channel,omitempty"` } // Position returns the node's position as a Point @@ -66,7 +66,7 @@ func (n *Node) GenerateMAC() string { for _, c := range n.ID { hash += int(c) } - return fmt.Sprintf("AA:BB:CC:DD:%02X:%02X", (hash&0xFF), ((hash>>8)&0xFF)) + return fmt.Sprintf("AA:BB:CC:DD:%02X:%02X", (hash & 0xFF), ((hash >> 8) & 0xFF)) } // NewNode creates a new node at the given position @@ -211,10 +211,10 @@ func CornerPositions(s *Space) []Point { height := (maxZ - minZ) / 2 // Average height return []Point{ - {X: minX, Y: minY, Z: height}, // Bottom-left, high - {X: maxX, Y: minY, Z: height}, // Bottom-right, high - {X: minX, Y: maxY, Z: height}, // Top-left, high - {X: maxX, Y: maxY, Z: height}, // Top-right, high + {X: minX, Y: minY, Z: height}, // Bottom-left, high + {X: maxX, Y: minY, Z: height}, // Bottom-right, high + {X: minX, Y: maxY, Z: height}, // Top-left, high + {X: maxX, Y: maxY, Z: height}, // Top-right, high {X: (minX + maxX) / 2, Y: minY, Z: 0.3}, // Bottom-middle, low {X: (minX + maxX) / 2, Y: maxY, Z: 0.3}, // Top-middle, low } diff --git a/mothership/internal/simulator/physics.go b/mothership/internal/simulator/physics.go index f0af846..5ac17c5 100644 --- a/mothership/internal/simulator/physics.go +++ b/mothership/internal/simulator/physics.go @@ -9,15 +9,15 @@ import ( // PhysicsModel provides physics calculations for CSI simulation type PhysicsModel struct { - space *Space - noiseSigma float64 // Gaussian noise standard deviation for I/Q - walls []WallDefinition + space *Space + noiseSigma float64 // Gaussian noise standard deviation for I/Q + walls []WallDefinition } // WallDefinition defines a wall segment for attenuation calculations type WallDefinition struct { X1, Y1, X2, Y2 float64 // Wall endpoints (floor coordinates) - Attenuation float64 // dB attenuation + Attenuation float64 // dB attenuation } // NewPhysicsModel creates a new physics model for the given space @@ -196,7 +196,7 @@ func (pm *PhysicsModel) PhaseAtSubcarrier(tx, rx, walker Point, subcarrierIndex, totalDist := d1 + d2 // Phase = 2π × k × Δf × (d / c) + temporal_variation - phase := 2*math.Pi*float64(subcarrierIndex)*SubcarrierSpacing*(totalDist/C) + phase := 2 * math.Pi * float64(subcarrierIndex) * SubcarrierSpacing * (totalDist / C) // Add small temporal variation for realism temporalPhase := 0.1 * math.Sin(2*math.Pi*float64(frameNum)/100.0) diff --git a/mothership/internal/simulator/propagation.go b/mothership/internal/simulator/propagation.go index 7f52930..c2252fe 100644 --- a/mothership/internal/simulator/propagation.go +++ b/mothership/internal/simulator/propagation.go @@ -444,7 +444,7 @@ func (pm *PropagationModel) PhaseAtSubcarrier(tx, rx, walker Point, subcarrierIn phase := 2 * math.Pi * float64(subcarrierIndex) * SubcarrierSpacing * (totalDist / C) // Add small temporal variation for realism - temporalPhase := 0.1 * math.Sin(2 * math.Pi * float64(frameNum) / 100.0) + temporalPhase := 0.1 * math.Sin(2*math.Pi*float64(frameNum)/100.0) phase += temporalPhase // Normalize to [-π, π] diff --git a/mothership/internal/simulator/registry_bridge.go b/mothership/internal/simulator/registry_bridge.go index 578c77d..b60775b 100644 --- a/mothership/internal/simulator/registry_bridge.go +++ b/mothership/internal/simulator/registry_bridge.go @@ -32,14 +32,14 @@ type RegistryNodeAdapter interface { // NodeRecord represents a node record from the fleet registry type NodeRecord struct { - MAC string - Name string - Role string - PosX float64 - PosY float64 - PosZ float64 - Virtual bool - Enabled bool + MAC string + Name string + Role string + PosX float64 + PosY float64 + PosZ float64 + Virtual bool + Enabled bool } // SyncToRegistry synchronizes all virtual nodes to the fleet registry @@ -208,11 +208,11 @@ func (b *FleetRegistryBridge) GetStore() *VirtualNodeStore { // CoverageOptimization represents optimization suggestions for virtual node placement type CoverageOptimization struct { - CurrentScore float64 `json:"current_score"` // Current coverage score (0-100) - RecommendedNodes int `json:"recommended_nodes"` // Recommended number of nodes - SuggestedPositions []Point `json:"suggested_positions"` // Suggested positions for new nodes - WeakAreas []Point `json:"weak_areas"` // Areas with poor coverage - ImprovementDelta float64 `json:"improvement_delta"` // Expected improvement with suggestions + CurrentScore float64 `json:"current_score"` // Current coverage score (0-100) + RecommendedNodes int `json:"recommended_nodes"` // Recommended number of nodes + SuggestedPositions []Point `json:"suggested_positions"` // Suggested positions for new nodes + WeakAreas []Point `json:"weak_areas"` // Areas with poor coverage + ImprovementDelta float64 `json:"improvement_delta"` // Expected improvement with suggestions } // OptimizeCoverage analyzes current coverage and suggests improvements diff --git a/mothership/internal/simulator/session.go b/mothership/internal/simulator/session.go index 4fc6bad..2d7e587 100644 --- a/mothership/internal/simulator/session.go +++ b/mothership/internal/simulator/session.go @@ -14,16 +14,16 @@ import ( // Session represents a simulation session. type Session struct { - mu sync.RWMutex - id string - space *Space - nodes []*VirtualNode - walkers []*Walker - params *SimulationParams - state SessionState - created_at int64 - updated_at int64 - ctx chan struct{} + mu sync.RWMutex + id string + space *Space + nodes []*VirtualNode + walkers []*Walker + params *SimulationParams + state SessionState + created_at int64 + updated_at int64 + ctx chan struct{} } // SessionState is the state of a simulation session. @@ -46,13 +46,13 @@ type VirtualNode struct { // SimulationParams holds simulation parameters. type SimulationParams struct { - TickRateHz int `json:"tick_rate_hz"` // 10 Hz default - WalkerSpeed float64 `json:"walker_speed"` // m/s - SignalAmplitude float64 `json:"signal_amplitude"` // 0.05 - FresnelSigma float64 `json:"fresnel_sigma"` // 0.3m - NoiseSigma float64 `json:"noise_sigma"` // Gaussian noise std dev - DefaultRSSI float64 `json:"default_rssi"` // -30 dBm at 1m - WallAttenuationDB float64 `json:"wall_attenuation_db"` // default 4 dB + TickRateHz int `json:"tick_rate_hz"` // 10 Hz default + WalkerSpeed float64 `json:"walker_speed"` // m/s + SignalAmplitude float64 `json:"signal_amplitude"` // 0.05 + FresnelSigma float64 `json:"fresnel_sigma"` // 0.3m + NoiseSigma float64 `json:"noise_sigma"` // Gaussian noise std dev + DefaultRSSI float64 `json:"default_rssi"` // -30 dBm at 1m + WallAttenuationDB float64 `json:"wall_attenuation_db"` // default 4 dB } // DefaultSimulationParams returns the default simulation parameters. diff --git a/mothership/internal/simulator/simulator_test.go b/mothership/internal/simulator/simulator_test.go index 507e7c4..57906ad 100644 --- a/mothership/internal/simulator/simulator_test.go +++ b/mothership/internal/simulator/simulator_test.go @@ -408,9 +408,9 @@ func TestExpectedAccuracy(t *testing.T) { minAccuracy float64 maxAccuracy float64 }{ - {1.0, 0.4, 0.6}, // GDOP 1: ~0.5m - {2.0, 0.8, 1.2}, // GDOP 2: ~1.0m - {4.0, 1.6, 2.4}, // GDOP 4: ~2.0m + {1.0, 0.4, 0.6}, // GDOP 1: ~0.5m + {2.0, 0.8, 1.2}, // GDOP 2: ~1.0m + {4.0, 1.6, 2.4}, // GDOP 4: ~2.0m {math.Inf(1), -1, -1}, // Infinity: no accuracy } diff --git a/mothership/internal/simulator/space.go b/mothership/internal/simulator/space.go index 74bcb0f..6f2f51d 100644 --- a/mothership/internal/simulator/space.go +++ b/mothership/internal/simulator/space.go @@ -88,12 +88,12 @@ func (p Point) Scale(f float64) Point { // WallSegment represents a flat wall with material properties type WallSegment struct { - ID string `json:"id"` - Name string `json:"name"` - Material WallMaterial `json:"material"` - P1 Point `json:"p1"` // Corner 1 (floor level) - P2 Point `json:"p2"` // Corner 2 (floor level) - Height float64 `json:"height"` + ID string `json:"id"` + Name string `json:"name"` + Material WallMaterial `json:"material"` + P1 Point `json:"p1"` // Corner 1 (floor level) + P2 Point `json:"p2"` // Corner 2 (floor level) + Height float64 `json:"height"` } // Bounds returns the axis-aligned bounding box of this wall @@ -156,15 +156,15 @@ func onSegment(px, py, qx, qy, rx, ry float64) bool { // Room defines a room in the virtual space type Room struct { - ID string `json:"id"` - Name string `json:"name"` - MinX float64 `json:"min_x"` - MinY float64 `json:"min_y"` - MinZ float64 `json:"min_z"` - MaxX float64 `json:"max_x"` - MaxY float64 `json:"max_y"` - MaxZ float64 `json:"max_z"` - Walls []WallSegment `json:"walls,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MinZ float64 `json:"min_z"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + MaxZ float64 `json:"max_z"` + Walls []WallSegment `json:"walls,omitempty"` } // Center returns the center point of the room @@ -196,10 +196,10 @@ func (r *Room) Contains(p Point) bool { // Space defines the virtual simulation space type Space struct { - ID string `json:"id"` - Name string `json:"name"` - Rooms []Room `json:"rooms"` - Walls []WallSegment `json:"walls"` + ID string `json:"id"` + Name string `json:"name"` + Rooms []Room `json:"rooms"` + Walls []WallSegment `json:"walls"` } // Bounds returns the overall bounding box of the space diff --git a/mothership/internal/simulator/space_test.go b/mothership/internal/simulator/space_test.go index c82c436..21859dc 100644 --- a/mothership/internal/simulator/space_test.go +++ b/mothership/internal/simulator/space_test.go @@ -92,8 +92,8 @@ func TestWallSegmentIntersectsLine(t *testing.T) { func TestWallPenetrationLoss(t *testing.T) { tests := []struct { - material WallMaterial - expected float64 + material WallMaterial + expected float64 }{ {MaterialDrywall, 3.0}, {MaterialBrick, 10.0}, diff --git a/mothership/internal/simulator/virtual_state.go b/mothership/internal/simulator/virtual_state.go index 14470f5..ef07655 100644 --- a/mothership/internal/simulator/virtual_state.go +++ b/mothership/internal/simulator/virtual_state.go @@ -14,14 +14,14 @@ import ( // VirtualNodeState represents the persistent state of a virtual node type VirtualNodeState struct { - ID string `json:"id"` - Name string `json:"name"` - Type NodeType `json:"type"` - Role NodeRole `json:"role"` - Position Point `json:"position"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Type NodeType `json:"type"` + Role NodeRole `json:"role"` + Position Point `json:"position"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // For AP nodes APBSSID string `json:"ap_bssid,omitempty"` APChannel int `json:"ap_channel,omitempty"` @@ -681,15 +681,15 @@ func (s *VirtualNodeStore) copyState(state *VirtualNodeState) *VirtualNodeState // VirtualNodeSummary provides a summary of virtual nodes in the space type VirtualNodeSummary struct { - TotalCount int `json:"total_count"` - EnabledCount int `json:"enabled_count"` - VirtualCount int `json:"virtual_count"` - APCount int `json:"ap_count"` - ByType map[string]int `json:"by_type"` - ByTag map[string]int `json:"by_tag"` - BoundingBox BoundingBox `json:"bounding_box"` - FirstCreated *time.Time `json:"first_created,omitempty"` - LastUpdated *time.Time `json:"last_updated,omitempty"` + TotalCount int `json:"total_count"` + EnabledCount int `json:"enabled_count"` + VirtualCount int `json:"virtual_count"` + APCount int `json:"ap_count"` + ByType map[string]int `json:"by_type"` + ByTag map[string]int `json:"by_tag"` + BoundingBox BoundingBox `json:"bounding_box"` + FirstCreated *time.Time `json:"first_created,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` } // BoundingBox represents the axis-aligned bounding box of all nodes diff --git a/mothership/internal/simulator/virtual_state_test.go b/mothership/internal/simulator/virtual_state_test.go index 3f20b26..1b4aa31 100644 --- a/mothership/internal/simulator/virtual_state_test.go +++ b/mothership/internal/simulator/virtual_state_test.go @@ -611,10 +611,10 @@ func TestVirtualNodeStore_UpdateSpace(t *testing.T) { ID: "smaller", Name: "Smaller Space", Rooms: []Room{{ - ID: "room-1", - Name: "Small Room", - MinX: 0, MinY: 0, MinZ: 0, - MaxX: 1.5, MaxY: 1.5, MaxZ: 1.5, + ID: "room-1", + Name: "Small Room", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 1.5, MaxY: 1.5, MaxZ: 1.5, }}, } @@ -634,10 +634,10 @@ func TestVirtualNodeStore_UpdateSpace(t *testing.T) { ID: "tiny", Name: "Tiny Space", Rooms: []Room{{ - ID: "room-1", - Name: "Tiny Room", - MinX: 0, MinY: 0, MinZ: 0, - MaxX: 0.5, MaxY: 0.5, MaxZ: 0.5, + ID: "room-1", + Name: "Tiny Room", + MinX: 0, MinY: 0, MinZ: 0, + MaxX: 0.5, MaxY: 0.5, MaxZ: 0.5, }}, } diff --git a/mothership/internal/simulator/walker.go b/mothership/internal/simulator/walker.go index 0d32782..15b8fb9 100644 --- a/mothership/internal/simulator/walker.go +++ b/mothership/internal/simulator/walker.go @@ -12,9 +12,9 @@ import ( type WalkerType string const ( - WalkerTypeRandomWalk WalkerType = "random_walk" // Random Gaussian walk - WalkerTypePathFollow WalkerType = "path_follow" // Follow a predefined path - WalkerTypeNodeToNode WalkerType = "node_to_node" // Traverse between virtual nodes + WalkerTypeRandomWalk WalkerType = "random_walk" // Random Gaussian walk + WalkerTypePathFollow WalkerType = "path_follow" // Follow a predefined path + WalkerTypeNodeToNode WalkerType = "node_to_node" // Traverse between virtual nodes ) // Walker represents a simulated person moving through the space @@ -24,17 +24,17 @@ type Walker struct { Position Point `json:"position"` Velocity Point `json:"velocity"` Type WalkerType `json:"type"` - Path []Point `json:"path,omitempty"` // For path-following mode - PathIndex int `json:"path_index,omitempty"` // Current position along path - Speed float64 `json:"speed"` // Movement speed in m/s - Height float64 `json:"height"` // Person height in meters + Path []Point `json:"path,omitempty"` // For path-following mode + PathIndex int `json:"path_index,omitempty"` // Current position along path + Speed float64 `json:"speed"` // Movement speed in m/s + Height float64 `json:"height"` // Person height in meters BLEAddress string `json:"ble_address,omitempty"` // Simulated BLE device // Node-to-node traversal fields - Nodes []*Node `json:"nodes,omitempty"` // List of nodes to visit - NodeIndex int `json:"node_index,omitempty"` // Current target node index - WaitTimer float64 `json:"wait_timer,omitempty"` // Time remaining at current node - WaitTime float64 `json:"wait_time,omitempty"` // How long to wait at each node (seconds) - ShouldWait bool `json:"should_wait,omitempty"` // Whether to wait at nodes + Nodes []*Node `json:"nodes,omitempty"` // List of nodes to visit + NodeIndex int `json:"node_index,omitempty"` // Current target node index + WaitTimer float64 `json:"wait_timer,omitempty"` // Time remaining at current node + WaitTime float64 `json:"wait_time,omitempty"` // How long to wait at each node (seconds) + ShouldWait bool `json:"should_wait,omitempty"` // Whether to wait at nodes } // NewWalker creates a new walker at the given position @@ -43,8 +43,8 @@ func NewWalker(id string, position Point) *Walker { ID: id, Position: position, Type: WalkerTypeRandomWalk, - Speed: 1.0, // 1 m/s default - Height: 1.7, // Average person height + Speed: 1.0, // 1 m/s default + Height: 1.7, // Average person height Velocity: Point{X: 0, Y: 0, Z: 0}, } } @@ -153,7 +153,7 @@ func (w *Walker) updateRandomWalk(dt float64, space *Space) { // Clamp velocity magnitude currentSpeed := math.Sqrt(w.Velocity.X*w.Velocity.X + w.Velocity.Y*w.Velocity.Y) - targetSpeed := w.Speed * (0.5 + rand.Float64()*0.5) // 50%-100% of set speed + targetSpeed := w.Speed * (0.5 + rand.Float64()*0.5) // 50%-100% of set speed newSpeed := currentSpeed + (targetSpeed-currentSpeed)*0.1 // Smooth speed change maxSpeed := w.Speed * 1.5 diff --git a/mothership/internal/simulator/walker_test.go b/mothership/internal/simulator/walker_test.go index d788bc0..6955b8e 100644 --- a/mothership/internal/simulator/walker_test.go +++ b/mothership/internal/simulator/walker_test.go @@ -339,7 +339,7 @@ func TestNodeToNodeWalkerDecelerationNearTarget(t *testing.T) { farSpeeds := make([]float64, 0, 10) for i := 0; i < 10; i++ { w := NewNodeToNodeWalkerNoWait(fmt.Sprintf("walker-far-%d", i), nodes, 1.0) - w.Position.X = 4.2 // 0.8m from node-2 + w.Position.X = 4.2 // 0.8m from node-2 w.Update(0.01, space) // Small update to compute velocity without moving much speed := math.Sqrt(w.Velocity.X*w.Velocity.X + w.Velocity.Y*w.Velocity.Y) if speed > 0 { @@ -351,7 +351,7 @@ func TestNodeToNodeWalkerDecelerationNearTarget(t *testing.T) { nearSpeeds := make([]float64, 0, 10) for i := 0; i < 10; i++ { w := NewNodeToNodeWalkerNoWait(fmt.Sprintf("walker-near-%d", i), nodes, 1.0) - w.Position.X = 4.8 // 0.2m from node-2 + w.Position.X = 4.8 // 0.2m from node-2 w.Update(0.01, space) // Small update to compute velocity speed := math.Sqrt(w.Velocity.X*w.Velocity.X + w.Velocity.Y*w.Velocity.Y) if speed > 0 { diff --git a/mothership/internal/sleep/analyzer.go b/mothership/internal/sleep/analyzer.go index 4332303..24c718e 100644 --- a/mothership/internal/sleep/analyzer.go +++ b/mothership/internal/sleep/analyzer.go @@ -19,18 +19,18 @@ const ( WakeConfirmDuration = 2 * time.Minute // Must be moving for 2 min to confirm wake // Scoring weights - BreathingWeight = 0.4 - MotionWeight = 0.3 + BreathingWeight = 0.4 + MotionWeight = 0.3 ContinuityWeight = 0.3 // Breathing quality thresholds - BreathingRateLow = 10.0 // BPM - below this is concerning - BreathingRateHigh = 25.0 // BPM - above this is concerning + BreathingRateLow = 10.0 // BPM - below this is concerning + BreathingRateHigh = 25.0 // BPM - above this is concerning BreathingRateOptimal = 14.0 // BPM - optimal breathing rate // Breathing anomaly thresholds (per task spec: <8 or >25 bpm) - BreathingAnomalyLow = 8.0 // BPM - apnea indicator - BreathingAnomalyHigh = 25.0 // BPM - hyperventilation indicator + BreathingAnomalyLow = 8.0 // BPM - apnea indicator + BreathingAnomalyHigh = 25.0 // BPM - hyperventilation indicator BreathingAnomalyDurationThreshold = 3 * time.Minute // Motion thresholds (deltaRMS) @@ -93,28 +93,28 @@ type BreathingSample struct { // MotionSample represents a motion measurement during sleep type MotionSample struct { - Timestamp time.Time `json:"timestamp"` - DeltaRMS float64 `json:"delta_rms"` - MotionDetected bool `json:"motion_detected"` + Timestamp time.Time `json:"timestamp"` + DeltaRMS float64 `json:"delta_rms"` + MotionDetected bool `json:"motion_detected"` } // SleepPeriod represents a continuous period of sleep type SleepPeriod struct { - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time,omitempty"` - Duration time.Duration `json:"duration"` - State SleepState `json:"state"` - Interruptions int `json:"interruptions"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + Duration time.Duration `json:"duration"` + State SleepState `json:"state"` + Interruptions int `json:"interruptions"` } // SleepMetrics aggregates metrics for a sleep session type SleepMetrics struct { // Timing - SleepStartTime time.Time `json:"sleep_start_time"` - SleepEndTime time.Time `json:"sleep_end_time,omitempty"` - SleepOnsetTime time.Time `json:"sleep_onset_time,omitempty"` // When sleep was confirmed (15 min stationary) - TotalDuration time.Duration `json:"total_duration"` - TimeInBed time.Duration `json:"time_in_bed"` + SleepStartTime time.Time `json:"sleep_start_time"` + SleepEndTime time.Time `json:"sleep_end_time,omitempty"` + SleepOnsetTime time.Time `json:"sleep_onset_time,omitempty"` // When sleep was confirmed (15 min stationary) + TotalDuration time.Duration `json:"total_duration"` + TimeInBed time.Duration `json:"time_in_bed"` // Sleep efficiency (per task spec: (time_in_bed - waso) / time_in_bed * 100) SleepEfficiency float64 `json:"sleep_efficiency"` // 0-100% @@ -123,31 +123,31 @@ type SleepMetrics struct { WakeEpisodeCount int `json:"wake_episode_count"` // Breathing metrics - AvgBreathingRate float64 `json:"avg_breathing_rate"` - MinBreathingRate float64 `json:"min_breathing_rate"` - MaxBreathingRate float64 `json:"max_breathing_rate"` - BreathingRateStdDev float64 `json:"breathing_rate_std_dev"` - BreathingRegularity float64 `json:"breathing_regularity"` // CV (std/mean) - BreathingScore float64 `json:"breathing_score"` // 0-100 - BreathingAnomalyCount int `json:"breathing_anomaly_count"` // Anomalies < 8 or > 25 bpm - BreathingAnomaly bool `json:"breathing_anomaly"` // Elevated vs personal average - PersonalAvgBPM float64 `json:"personal_avg_bpm,omitempty"` // Person's rolling average for comparison - BreathingSamplesJSON string `json:"breathing_samples_json,omitempty"` // Raw samples for storage + AvgBreathingRate float64 `json:"avg_breathing_rate"` + MinBreathingRate float64 `json:"min_breathing_rate"` + MaxBreathingRate float64 `json:"max_breathing_rate"` + BreathingRateStdDev float64 `json:"breathing_rate_std_dev"` + BreathingRegularity float64 `json:"breathing_regularity"` // CV (std/mean) + BreathingScore float64 `json:"breathing_score"` // 0-100 + BreathingAnomalyCount int `json:"breathing_anomaly_count"` // Anomalies < 8 or > 25 bpm + BreathingAnomaly bool `json:"breathing_anomaly"` // Elevated vs personal average + PersonalAvgBPM float64 `json:"personal_avg_bpm,omitempty"` // Person's rolling average for comparison + BreathingSamplesJSON string `json:"breathing_samples_json,omitempty"` // Raw samples for storage // Motion metrics - MotionEvents int `json:"motion_events"` - RestlessPeriods int `json:"restless_periods"` - QuietTimePct float64 `json:"quiet_time_pct"` - MotionScore float64 `json:"motion_score"` // 0-100 + MotionEvents int `json:"motion_events"` + RestlessPeriods int `json:"restless_periods"` + QuietTimePct float64 `json:"quiet_time_pct"` + MotionScore float64 `json:"motion_score"` // 0-100 // Sleep continuity - Interruptions int `json:"interruptions"` + Interruptions int `json:"interruptions"` LongestDeepPeriod time.Duration `json:"longest_deep_period"` - ContinuityScore float64 `json:"continuity_score"` // 0-100 + ContinuityScore float64 `json:"continuity_score"` // 0-100 // Overall score - OverallScore float64 `json:"overall_score"` // 0-100 - QualityRating string `json:"quality_rating"` // poor/fair/good/excellent + OverallScore float64 `json:"overall_score"` // 0-100 + QualityRating string `json:"quality_rating"` // poor/fair/good/excellent } // Breathing anomaly thresholds are defined above (lines 32-34) @@ -164,11 +164,11 @@ type WakeEpisode struct { // BreathingAnomaly represents a detected breathing anomaly type BreathingAnomaly struct { - ID string `json:"id"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time,omitempty"` - RateBPM float64 `json:"rate_bpm"` - AnomalyType string `json:"anomaly_type"` // "low" or "high" + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + RateBPM float64 `json:"rate_bpm"` + AnomalyType string `json:"anomaly_type"` // "low" or "high" Duration time.Duration `json:"duration"` } @@ -181,27 +181,27 @@ type SleepSession struct { sleepEndHour int // State - currentState SleepState - sessionDate time.Time // Date of sleep session (midnight of the night) - isActive bool + currentState SleepState + sessionDate time.Time // Date of sleep session (midnight of the night) + isActive bool // Session timing - sessionStart time.Time // When person entered bedroom/started tracking - sleepOnset time.Time // When sleep was confirmed (15 min after stationary detection) - wakeTime time.Time // When session ended + sessionStart time.Time // When person entered bedroom/started tracking + sleepOnset time.Time // When sleep was confirmed (15 min after stationary detection) + wakeTime time.Time // When session ended // Sample buffers breathingSamples []BreathingSample motionSamples []MotionSample // Period tracking - sleepPeriods []SleepPeriod + sleepPeriods []SleepPeriod currentPeriod *SleepPeriod // Wake episode tracking - wakeEpisodes []WakeEpisode + wakeEpisodes []WakeEpisode currentWakeEpisode *WakeEpisode - wakeEpisodeStart time.Time // Track when current wake period started + wakeEpisodeStart time.Time // Track when current wake period started // Breathing anomaly tracking breathingAnomalies []BreathingAnomaly @@ -241,10 +241,10 @@ type SleepAnalyzer struct { // NewSleepAnalyzer creates a new sleep analyzer func NewSleepAnalyzer() *SleepAnalyzer { return &SleepAnalyzer{ - sessions: make(map[string]*SleepSession), - sleepStartHour: DefaultSleepStartHour, - sleepEndHour: DefaultSleepEndHour, - anomalyTracker: NewBreathingAnomalyTracker(), + sessions: make(map[string]*SleepSession), + sleepStartHour: DefaultSleepStartHour, + sleepEndHour: DefaultSleepEndHour, + anomalyTracker: NewBreathingAnomalyTracker(), } } @@ -377,14 +377,14 @@ func (sa *SleepAnalyzer) SetPersonID(linkID, personID string) { // NewSleepSession creates a new sleep session func NewSleepSession(linkID string, sleepStartHour, sleepEndHour int) *SleepSession { return &SleepSession{ - linkID: linkID, - sleepStartHour: sleepStartHour, - sleepEndHour: sleepEndHour, - currentState: SleepStateAwake, - breathingSamples: make([]BreathingSample, 0, 1440), // ~12 hours at 30s intervals - motionSamples: make([]MotionSample, 0, 1440), - sleepPeriods: make([]SleepPeriod, 0, 100), - wakeEpisodes: make([]WakeEpisode, 0, 50), + linkID: linkID, + sleepStartHour: sleepStartHour, + sleepEndHour: sleepEndHour, + currentState: SleepStateAwake, + breathingSamples: make([]BreathingSample, 0, 1440), // ~12 hours at 30s intervals + motionSamples: make([]MotionSample, 0, 1440), + sleepPeriods: make([]SleepPeriod, 0, 100), + wakeEpisodes: make([]WakeEpisode, 0, 50), breathingAnomalies: make([]BreathingAnomaly, 0, 20), } } @@ -914,10 +914,10 @@ func (ss *SleepSession) GenerateReport() *SleepReport { } report := &SleepReport{ - LinkID: ss.linkID, - SessionDate: ss.sessionDate, - GeneratedAt: time.Now(), - Metrics: metrics, + LinkID: ss.linkID, + SessionDate: ss.sessionDate, + GeneratedAt: time.Now(), + Metrics: metrics, BreathingSamples: breathingSamples, BreathingSummary: generateBreathingSummary(metrics), MotionSummary: generateMotionSummary(metrics), diff --git a/mothership/internal/sleep/breathing_acceptance_test.go b/mothership/internal/sleep/breathing_acceptance_test.go index eb8fdcb..76385ec 100644 --- a/mothership/internal/sleep/breathing_acceptance_test.go +++ b/mothership/internal/sleep/breathing_acceptance_test.go @@ -83,12 +83,12 @@ func TestAcceptance_AnomalyTriggersAtThreshold(t *testing.T) { // TestAcceptance_BreathingRegularityLabels verifies regularity CV thresholds. func TestAcceptance_BreathingRegularityLabels(t *testing.T) { tests := []struct { - cv float64 - want string + cv float64 + want string }{ - {0.05, "regular"}, // CV < 0.10 + {0.05, "regular"}, // CV < 0.10 {0.09, "regular"}, - {0.10, "normal"}, // boundary + {0.10, "normal"}, // boundary {0.15, "normal"}, {0.25, "normal"}, // boundary {0.26, "irregular"}, // CV > 0.25 @@ -182,8 +182,8 @@ func TestAcceptance_SleepReportIncludesAnomaly(t *testing.T) { // TestAcceptance_FFTRejectsOutOfRange verifies FFT rejects frequencies outside 6-25 bpm. func TestAcceptance_FFTRejectsOutOfRange(t *testing.T) { tests := []struct { - name string - freqHz float64 + name string + freqHz float64 inRange bool // true if FFT should detect a value in 6-25 bpm }{ {"0.05 Hz = 3 bpm (below range)", 0.05, false}, diff --git a/mothership/internal/sleep/breathing_anomaly.go b/mothership/internal/sleep/breathing_anomaly.go index cc6af1c..88798eb 100644 --- a/mothership/internal/sleep/breathing_anomaly.go +++ b/mothership/internal/sleep/breathing_anomaly.go @@ -9,9 +9,9 @@ import ( // Anomaly detection constants const ( - BreathingAnomalyEmaAlpha = 0.05 // Rolling personal average EMA (slow, ~20-night half-life) - BreathingAnomalyThreshold = 1.25 // Flag if avg > personal_avg × 1.25 - BreathingRegularityRegular = 0.10 // CV below this = regular + BreathingAnomalyEmaAlpha = 0.05 // Rolling personal average EMA (slow, ~20-night half-life) + BreathingAnomalyThreshold = 1.25 // Flag if avg > personal_avg × 1.25 + BreathingRegularityRegular = 0.10 // CV below this = regular BreathingRegularityIrregular = 0.25 // CV above this = irregular ) diff --git a/mothership/internal/sleep/breathing_anomaly_test.go b/mothership/internal/sleep/breathing_anomaly_test.go index 6e1b1f2..6d06d0f 100644 --- a/mothership/internal/sleep/breathing_anomaly_test.go +++ b/mothership/internal/sleep/breathing_anomaly_test.go @@ -12,17 +12,17 @@ func TestBreathingAnomalyTrackerCheckAnomaly(t *testing.T) { tracker.UpdatePersonalAverage("alice", 16.0) tests := []struct { - name string - avgBPM float64 - personal float64 + name string + avgBPM float64 + personal float64 wantAnomaly bool }{ {"normal rate", 16.0, 16.0, false}, - {"slightly elevated", 19.0, 16.0, false}, // 19/16 = 1.1875 < 1.25 - {"at threshold", 20.0, 16.0, false}, // 20/16 = 1.25 = threshold (not >) - {"above threshold", 21.0, 16.0, true}, // 21/16 = 1.3125 > 1.25 - {"significantly elevated", 25.0, 16.0, true}, // 25/16 = 1.5625 - {"below average", 12.0, 16.0, false}, // 12/16 = 0.75 + {"slightly elevated", 19.0, 16.0, false}, // 19/16 = 1.1875 < 1.25 + {"at threshold", 20.0, 16.0, false}, // 20/16 = 1.25 = threshold (not >) + {"above threshold", 21.0, 16.0, true}, // 21/16 = 1.3125 > 1.25 + {"significantly elevated", 25.0, 16.0, true}, // 25/16 = 1.5625 + {"below average", 12.0, 16.0, false}, // 12/16 = 0.75 } for _, tt := range tests { diff --git a/mothership/internal/sleep/breathing_estimator.go b/mothership/internal/sleep/breathing_estimator.go index 7c94b30..e3249a9 100644 --- a/mothership/internal/sleep/breathing_estimator.go +++ b/mothership/internal/sleep/breathing_estimator.go @@ -11,14 +11,14 @@ import ( // FFT estimator constants const ( - FFTEstimatorSampleRate = 20.0 // Hz — matches signal pipeline - FFTEstimatorFFTSize = 512 // Input window (25.6 s at 20 Hz) - FFTEstimatorZeroPad = 1024 // Zero-padded FFT size - FFTEstimatorEMAlpha = 1.0 / 60.0 // 60-second EMA smoothing - FFTEstimatorMinHz = 0.1 // 6 BPM lower bound - FFTEstimatorMaxHz = 0.5 // 30 BPM upper bound - FFTEstimatorMinBPM = 6.0 - FFTEstimatorMaxBPM = 30.0 + FFTEstimatorSampleRate = 20.0 // Hz — matches signal pipeline + FFTEstimatorFFTSize = 512 // Input window (25.6 s at 20 Hz) + FFTEstimatorZeroPad = 1024 // Zero-padded FFT size + FFTEstimatorEMAlpha = 1.0 / 60.0 // 60-second EMA smoothing + FFTEstimatorMinHz = 0.1 // 6 BPM lower bound + FFTEstimatorMaxHz = 0.5 // 30 BPM upper bound + FFTEstimatorMinBPM = 6.0 + FFTEstimatorMaxBPM = 30.0 ) // BreathingRateEstimator accumulates phase samples and estimates breathing rate via FFT. diff --git a/mothership/internal/sleep/breathing_estimator_test.go b/mothership/internal/sleep/breathing_estimator_test.go index 2db1662..bcad75b 100644 --- a/mothership/internal/sleep/breathing_estimator_test.go +++ b/mothership/internal/sleep/breathing_estimator_test.go @@ -47,7 +47,7 @@ func TestComputeFFTBreathingRateSynthetic20BPM(t *testing.T) { sampleRate := 20.0 buffer := make([]float64, 512) for i := range buffer { - buffer[i] = math.Sin(2.0 * math.Pi * (1.0/3.0) * float64(i) / sampleRate) + buffer[i] = math.Sin(2.0 * math.Pi * (1.0 / 3.0) * float64(i) / sampleRate) } bpm := computeFFTBreathingRate(buffer, 0, sampleRate, 1024) @@ -174,10 +174,10 @@ func TestBreathingRateEstimatorReset(t *testing.T) { func TestComputeBreathingRegularity(t *testing.T) { tests := []struct { - name string - samples []float64 - wantCV float64 - tol float64 + name string + samples []float64 + wantCV float64 + tol float64 }{ { name: "constant rate — zero CV", @@ -229,14 +229,14 @@ func TestComputeBreathingRegularity(t *testing.T) { func TestBreathingRegularityLabel(t *testing.T) { tests := []struct { - cv float64 - want string + cv float64 + want string }{ {0.05, "regular"}, {0.09, "regular"}, - {0.10, "normal"}, // boundary + {0.10, "normal"}, // boundary {0.15, "normal"}, - {0.25, "normal"}, // boundary + {0.25, "normal"}, // boundary {0.26, "irregular"}, {0.50, "irregular"}, } diff --git a/mothership/internal/sleep/handler.go b/mothership/internal/sleep/handler.go index 78b5506..8c50f7d 100644 --- a/mothership/internal/sleep/handler.go +++ b/mothership/internal/sleep/handler.go @@ -141,12 +141,12 @@ func (h *Handler) handleGetSessions(w http.ResponseWriter, r *http.Request) { for linkID, session := range sessions { session.mu.RLock() summary := map[string]interface{}{ - "link_id": linkID, - "current_state": session.GetCurrentState().String(), - "is_active": session.isActive, + "link_id": linkID, + "current_state": session.GetCurrentState().String(), + "is_active": session.isActive, "breathing_samples": len(session.breathingSamples), - "motion_samples": len(session.motionSamples), - "sleep_periods": len(session.sleepPeriods), + "motion_samples": len(session.motionSamples), + "sleep_periods": len(session.sleepPeriods), } if !session.sessionDate.IsZero() { @@ -306,8 +306,8 @@ func (h *Handler) handleGetSamples(w http.ResponseWriter, r *http.Request) { periods := make([]map[string]interface{}, len(session.sleepPeriods)) for i, p := range session.sleepPeriods { period := map[string]interface{}{ - "state": p.State.String(), - "start_time": p.StartTime.Unix(), + "state": p.State.String(), + "start_time": p.StartTime.Unix(), "duration_seconds": p.Duration.Seconds(), } if !p.EndTime.IsZero() { diff --git a/mothership/internal/sleep/integration.go b/mothership/internal/sleep/integration.go index 32de595..e07fd40 100644 --- a/mothership/internal/sleep/integration.go +++ b/mothership/internal/sleep/integration.go @@ -15,23 +15,23 @@ import ( type SessionState int const ( - SessionStateNone SessionState = iota - SessionStateTentative // In bedroom, stationary detected, waiting for 15-min confirmation - SessionStateConfirmed // Sleep session confirmed (15 min stationary) - SessionStateEnded // Session ended, waiting for morning report + SessionStateNone SessionState = iota + SessionStateTentative // In bedroom, stationary detected, waiting for 15-min confirmation + SessionStateConfirmed // Sleep session confirmed (15 min stationary) + SessionStateEnded // Session ended, waiting for morning report ) // LinkSessionState tracks the sleep session state per link type LinkSessionState struct { - State SessionState - TentativeStartTime time.Time // When tentative detection started - ConfirmedStartTime time.Time // When sleep was confirmed (15 min after tentative) - SessionID string - ZoneID string - PersonID string - LastStationaryTime time.Time // Last time stationary was detected - LastMotionTime time.Time // Last time motion was detected - InBedroomZone bool + State SessionState + TentativeStartTime time.Time // When tentative detection started + ConfirmedStartTime time.Time // When sleep was confirmed (15 min after tentative) + SessionID string + ZoneID string + PersonID string + LastStationaryTime time.Time // Last time stationary was detected + LastMotionTime time.Time // Last time motion was detected + InBedroomZone bool SustainedMotionStart time.Time // When sustained motion started (for wake detection) } @@ -55,13 +55,13 @@ type Monitor struct { wakeConfirmMinutes int // Minutes of sustained motion to confirm wake (default 2) // State - running bool - stopCh chan struct{} - lastSample map[string]time.Time - lastReport time.Time - linkSessionStates map[string]*LinkSessionState // Per-link session tracking - firstConnectionToday bool // Track if morning summary was pushed today - morningSummaryPushed time.Time // When morning summary was last pushed + running bool + stopCh chan struct{} + lastSample map[string]time.Time + lastReport time.Time + linkSessionStates map[string]*LinkSessionState // Per-link session tracking + firstConnectionToday bool // Track if morning summary was pushed today + morningSummaryPushed time.Time // When morning summary was last pushed // Event callbacks onSessionStart func(event events.SleepSessionStartEvent) @@ -383,8 +383,8 @@ func (m *Monitor) checkSessionEnd(linkID string, now time.Time) { // Fire session end callback if m.onSessionEnd != nil { m.onSessionEnd(events.SleepSessionEndEvent{ - ZoneID: ls.ZoneID, - PersonID: ls.PersonID, + ZoneID: ls.ZoneID, + PersonID: ls.PersonID, StartTimestamp: ls.ConfirmedStartTime, EndTimestamp: now, DurationMin: now.Sub(ls.ConfirmedStartTime).Minutes(), @@ -429,8 +429,8 @@ func (m *Monitor) NotifyZoneTransition(linkID string, zoneID string, entered boo if m.onSessionEnd != nil { m.onSessionEnd(events.SleepSessionEndEvent{ - ZoneID: ls.ZoneID, - PersonID: ls.PersonID, + ZoneID: ls.ZoneID, + PersonID: ls.PersonID, StartTimestamp: ls.ConfirmedStartTime, EndTimestamp: now, DurationMin: now.Sub(ls.ConfirmedStartTime).Minutes(), @@ -475,20 +475,20 @@ func (m *Monitor) ShouldPushMorningSummary() (bool, map[string]interface{}) { m.morningSummaryPushed = now // Convert report to map[string]interface{} format summaryMap := map[string]interface{}{ - "link_id": report.LinkID, - "session_date": report.SessionDate.Format("2006-01-02"), - "generated_at": report.GeneratedAt.UnixMilli(), - "overall_score": report.Metrics.OverallScore, - "quality_rating": report.Metrics.QualityRating, + "link_id": report.LinkID, + "session_date": report.SessionDate.Format("2006-01-02"), + "generated_at": report.GeneratedAt.UnixMilli(), + "overall_score": report.Metrics.OverallScore, + "quality_rating": report.Metrics.QualityRating, "breathing_summary": report.BreathingSummary, "motion_summary": report.MotionSummary, "recommendations": report.Recommendations, "metrics": map[string]interface{}{ "total_duration_hours": report.Metrics.TotalDuration.Hours(), "time_in_bed_hours": report.Metrics.TimeInBed.Hours(), - "sleep_efficiency": report.Metrics.SleepEfficiency, - "sleep_latency_minutes": report.Metrics.SleepLatencyMinutes, - "waso_minutes": report.Metrics.WASOMinutes, + "sleep_efficiency": report.Metrics.SleepEfficiency, + "sleep_latency_minutes": report.Metrics.SleepLatencyMinutes, + "waso_minutes": report.Metrics.WASOMinutes, "wake_episode_count": report.Metrics.WakeEpisodeCount, "avg_breathing_rate": report.Metrics.AvgBreathingRate, "breathing_rate_std_dev": report.Metrics.BreathingRateStdDev, @@ -608,12 +608,12 @@ type SleepStatus struct { // SleepLinkState represents sleep state for a single link type SleepLinkState struct { - LinkID string `json:"link_id"` - SleepState string `json:"sleep_state"` - SamplesCollected int `json:"samples_collected"` - SessionActive bool `json:"session_active"` + LinkID string `json:"link_id"` + SleepState string `json:"sleep_state"` + SamplesCollected int `json:"samples_collected"` + SessionActive bool `json:"session_active"` CurrentBreathingRate float64 `json:"current_breathing_rate"` - CurrentMotion bool `json:"current_motion"` + CurrentMotion bool `json:"current_motion"` } // GetStatus returns the current sleep monitoring status @@ -633,8 +633,8 @@ func (m *Monitor) GetStatus() SleepStatus { for linkID, session := range sessions { state := SleepLinkState{ - LinkID: linkID, - SleepState: session.GetCurrentState().String(), + LinkID: linkID, + SleepState: session.GetCurrentState().String(), SessionActive: session.isActive, } diff --git a/mothership/internal/sleep/integration_test.go b/mothership/internal/sleep/integration_test.go index 2bd0f69..63e63eb 100644 --- a/mothership/internal/sleep/integration_test.go +++ b/mothership/internal/sleep/integration_test.go @@ -8,10 +8,10 @@ import ( func TestNewMonitor(t *testing.T) { cfg := MonitorConfig{ - SampleInterval: 15 * time.Second, - ReportHour: 7, - SleepStartHour: 22, - SleepEndHour: 7, + SampleInterval: 15 * time.Second, + ReportHour: 7, + SleepStartHour: 22, + SleepEndHour: 7, } m := NewMonitor(cfg) diff --git a/mothership/internal/sleep/monitor_test.go b/mothership/internal/sleep/monitor_test.go index 0e3b040..8f5c937 100644 --- a/mothership/internal/sleep/monitor_test.go +++ b/mothership/internal/sleep/monitor_test.go @@ -270,7 +270,7 @@ func TestSleepEfficiencyCalculation(t *testing.T) { metrics := ss.GetMetrics() expectedEfficiency := (480.0 - 45.0) / 480.0 * 100.0 // 90.625% - tolerance := 5.0 // Allow tolerance due to timing granularity + tolerance := 5.0 // Allow tolerance due to timing granularity if math.Abs(metrics.SleepEfficiency-expectedEfficiency) > tolerance { t.Errorf("expected sleep efficiency ~%.1f%%, got %.1f%%", expectedEfficiency, metrics.SleepEfficiency) diff --git a/mothership/internal/sleep/records.go b/mothership/internal/sleep/records.go index 3b29a0f..0b09586 100644 --- a/mothership/internal/sleep/records.go +++ b/mothership/internal/sleep/records.go @@ -11,20 +11,20 @@ import ( // SleepRecord represents a row in the sleep_records table (main spaxel.db). type SleepRecord struct { - ID int64 `json:"id"` - Person string `json:"person,omitempty"` - ZoneID *int `json:"zone_id,omitempty"` - Date string `json:"date"` - BedTimeMs *int64 `json:"bed_time_ms,omitempty"` - WakeTimeMs *int64 `json:"wake_time_ms,omitempty"` - DurationMin *int `json:"duration_min,omitempty"` - OnsetLatencyMin *float64 `json:"onset_latency_min,omitempty"` - Restlessness *float64 `json:"restlessness,omitempty"` - BreathingRateAvg *float64 `json:"breathing_rate_avg,omitempty"` - BreathingRegularity *float64 `json:"breathing_regularity,omitempty"` - BreathingAnomaly *bool `json:"breathing_anomaly,omitempty"` - BreathingSamplesJSON *string `json:"breathing_samples_json,omitempty"` - SummaryJSON *string `json:"summary_json,omitempty"` + ID int64 `json:"id"` + Person string `json:"person,omitempty"` + ZoneID *int `json:"zone_id,omitempty"` + Date string `json:"date"` + BedTimeMs *int64 `json:"bed_time_ms,omitempty"` + WakeTimeMs *int64 `json:"wake_time_ms,omitempty"` + DurationMin *int `json:"duration_min,omitempty"` + OnsetLatencyMin *float64 `json:"onset_latency_min,omitempty"` + Restlessness *float64 `json:"restlessness,omitempty"` + BreathingRateAvg *float64 `json:"breathing_rate_avg,omitempty"` + BreathingRegularity *float64 `json:"breathing_regularity,omitempty"` + BreathingAnomaly *bool `json:"breathing_anomaly,omitempty"` + BreathingSamplesJSON *string `json:"breathing_samples_json,omitempty"` + SummaryJSON *string `json:"summary_json,omitempty"` } // SleepRecordStore handles persistence of sleep records against the main DB. diff --git a/mothership/internal/sleep/report.go b/mothership/internal/sleep/report.go index 9abdbb4..23ddb3d 100644 --- a/mothership/internal/sleep/report.go +++ b/mothership/internal/sleep/report.go @@ -7,9 +7,9 @@ import ( // SleepReport represents a complete sleep quality report type SleepReport struct { - LinkID string `json:"link_id"` - SessionDate time.Time `json:"session_date"` - GeneratedAt time.Time `json:"generated_at"` + LinkID string `json:"link_id"` + SessionDate time.Time `json:"session_date"` + GeneratedAt time.Time `json:"generated_at"` Metrics *SleepMetrics `json:"metrics"` // Raw breathing rate samples (BPM values collected per ~60s window during sleep) @@ -194,11 +194,11 @@ func formatMinutes(n int) string { // ToJSONMap converts the report to a map for JSON serialization func (r *SleepReport) ToJSONMap() map[string]interface{} { m := map[string]interface{}{ - "link_id": r.LinkID, - "session_date": r.SessionDate.Format("2006-01-02"), - "generated_at": r.GeneratedAt.UnixMilli(), - "overall_score": r.Metrics.OverallScore, - "quality_rating": r.Metrics.QualityRating, + "link_id": r.LinkID, + "session_date": r.SessionDate.Format("2006-01-02"), + "generated_at": r.GeneratedAt.UnixMilli(), + "overall_score": r.Metrics.OverallScore, + "quality_rating": r.Metrics.QualityRating, "breathing_summary": r.BreathingSummary, "motion_summary": r.MotionSummary, "recommendations": r.Recommendations, @@ -206,21 +206,21 @@ func (r *SleepReport) ToJSONMap() map[string]interface{} { // Add detailed metrics metricsMap := map[string]interface{}{ - "total_duration_hours": r.Metrics.TotalDuration.Hours(), - "time_in_bed_hours": r.Metrics.TimeInBed.Hours(), - "avg_breathing_rate": r.Metrics.AvgBreathingRate, - "breathing_rate_std_dev": r.Metrics.BreathingRateStdDev, - "breathing_regularity": r.Metrics.BreathingRegularity, - "breathing_score": r.Metrics.BreathingScore, - "breathing_anomaly": r.Metrics.BreathingAnomaly, - "breathing_anomaly_count": r.Metrics.BreathingAnomalyCount, - "quiet_time_pct": r.Metrics.QuietTimePct, - "motion_events": r.Metrics.MotionEvents, - "restless_periods": r.Metrics.RestlessPeriods, - "motion_score": r.Metrics.MotionScore, - "interruptions": r.Metrics.Interruptions, + "total_duration_hours": r.Metrics.TotalDuration.Hours(), + "time_in_bed_hours": r.Metrics.TimeInBed.Hours(), + "avg_breathing_rate": r.Metrics.AvgBreathingRate, + "breathing_rate_std_dev": r.Metrics.BreathingRateStdDev, + "breathing_regularity": r.Metrics.BreathingRegularity, + "breathing_score": r.Metrics.BreathingScore, + "breathing_anomaly": r.Metrics.BreathingAnomaly, + "breathing_anomaly_count": r.Metrics.BreathingAnomalyCount, + "quiet_time_pct": r.Metrics.QuietTimePct, + "motion_events": r.Metrics.MotionEvents, + "restless_periods": r.Metrics.RestlessPeriods, + "motion_score": r.Metrics.MotionScore, + "interruptions": r.Metrics.Interruptions, "longest_deep_period_mins": r.Metrics.LongestDeepPeriod.Minutes(), - "continuity_score": r.Metrics.ContinuityScore, + "continuity_score": r.Metrics.ContinuityScore, } // Add breathing rate range diff --git a/mothership/internal/sleep/storage.go b/mothership/internal/sleep/storage.go index f20d86f..3512400 100644 --- a/mothership/internal/sleep/storage.go +++ b/mothership/internal/sleep/storage.go @@ -25,24 +25,24 @@ type Storage struct { // SleepSessionRecord represents a persisted sleep session. type SleepSessionRecord struct { - ID string `json:"id"` - PersonID string `json:"person_id"` - LinkID string `json:"link_id"` - ZoneID string `json:"zone_id"` - SessionDate time.Time `json:"session_date"` - SleepOnset time.Time `json:"sleep_onset"` - WakeTime time.Time `json:"wake_time,omitempty"` - TimeInBedMinutes float64 `json:"time_in_bed_minutes"` - SleepLatencyMinutes float64 `json:"sleep_latency_minutes"` - WakeEpisodeCount int `json:"wake_episode_count"` - WASOMinutes float64 `json:"waso_minutes"` - BreathingRateMean float64 `json:"breathing_rate_mean"` - BreathingRateStdDev float64 `json:"breathing_rate_std_dev"` - BreathingAnomalyCount int `json:"breathing_anomaly_count"` - SleepEfficiency float64 `json:"sleep_efficiency"` - OverallScore float64 `json:"overall_score"` - QualityRating string `json:"quality_rating"` - WakeEpisodes []WakeEpisode `json:"wake_episodes,omitempty"` + ID string `json:"id"` + PersonID string `json:"person_id"` + LinkID string `json:"link_id"` + ZoneID string `json:"zone_id"` + SessionDate time.Time `json:"session_date"` + SleepOnset time.Time `json:"sleep_onset"` + WakeTime time.Time `json:"wake_time,omitempty"` + TimeInBedMinutes float64 `json:"time_in_bed_minutes"` + SleepLatencyMinutes float64 `json:"sleep_latency_minutes"` + WakeEpisodeCount int `json:"wake_episode_count"` + WASOMinutes float64 `json:"waso_minutes"` + BreathingRateMean float64 `json:"breathing_rate_mean"` + BreathingRateStdDev float64 `json:"breathing_rate_std_dev"` + BreathingAnomalyCount int `json:"breathing_anomaly_count"` + SleepEfficiency float64 `json:"sleep_efficiency"` + OverallScore float64 `json:"overall_score"` + QualityRating string `json:"quality_rating"` + WakeEpisodes []WakeEpisode `json:"wake_episodes,omitempty"` } // NewStorage creates a new storage instance. @@ -516,13 +516,13 @@ func (s *Storage) GetWeeklyTrends(personID string) (*WeeklyTrends, error) { // WeeklyTrends holds aggregated weekly sleep statistics. type WeeklyTrends struct { - DailyDurations []float64 `json:"daily_durations"` - DailyEfficiencies []float64 `json:"daily_efficiencies"` - DailyDates []string `json:"daily_dates"` - AvgDurationMinutes float64 `json:"avg_duration_minutes"` - AvgEfficiency float64 `json:"avg_efficiency"` - AvgBreathingRate float64 `json:"avg_breathing_rate"` - NightsCount int `json:"nights_count"` + DailyDurations []float64 `json:"daily_durations"` + DailyEfficiencies []float64 `json:"daily_efficiencies"` + DailyDates []string `json:"daily_dates"` + AvgDurationMinutes float64 `json:"avg_duration_minutes"` + AvgEfficiency float64 `json:"avg_efficiency"` + AvgBreathingRate float64 `json:"avg_breathing_rate"` + NightsCount int `json:"nights_count"` } // DeleteOldSessions deletes sessions older than the specified days. diff --git a/mothership/internal/timeline/timeline.go b/mothership/internal/timeline/timeline.go index 61e5174..0003ccb 100644 --- a/mothership/internal/timeline/timeline.go +++ b/mothership/internal/timeline/timeline.go @@ -40,13 +40,13 @@ type Event struct { // It subscribes to the EventBus and writes events to SQLite // without blocking publishers. type Storage struct { - db *sql.DB - queue chan Event - done chan struct{} - wg sync.WaitGroup - mu sync.Mutex - dropped int // Counter for dropped events (for metrics) - lastWarn time.Time + db *sql.DB + queue chan Event + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + dropped int // Counter for dropped events (for metrics) + lastWarn time.Time } // New creates a new timeline storage subscriber. diff --git a/mothership/internal/tracker/ble_provider.go b/mothership/internal/tracker/ble_provider.go index 822fccf..ff7ca29 100644 --- a/mothership/internal/tracker/ble_provider.go +++ b/mothership/internal/tracker/ble_provider.go @@ -38,11 +38,11 @@ func (p *BLEIdentityProvider) GetIdentity(blobID int) *IdentityInfo { source = "ble_only" } return &IdentityInfo{ - PersonID: match.PersonID, - PersonLabel: match.PersonName, - PersonColor: match.PersonColor, + PersonID: match.PersonID, + PersonLabel: match.PersonName, + PersonColor: match.PersonColor, IdentityConfidence: match.Confidence, - IdentitySource: source, + IdentitySource: source, } } @@ -53,11 +53,11 @@ func (p *BLEIdentityProvider) GetIdentity(blobID int) *IdentityInfo { source = "ble_only" } return &IdentityInfo{ - PersonID: persist.PersonID, - PersonLabel: persist.PersonName, - PersonColor: persist.PersonColor, + PersonID: persist.PersonID, + PersonLabel: persist.PersonName, + PersonColor: persist.PersonColor, IdentityConfidence: persist.Confidence, - IdentitySource: source, + IdentitySource: source, } } diff --git a/mothership/internal/tracker/identity.go b/mothership/internal/tracker/identity.go index c31fc2e..6c8d33c 100644 --- a/mothership/internal/tracker/identity.go +++ b/mothership/internal/tracker/identity.go @@ -8,23 +8,23 @@ import ( // IdentityMatcher interface for dependency injection from ble package. type IdentityMatcher interface { GetMatch(blobID int) *struct { - PersonID string - PersonName string - PersonColor string - Confidence float64 - IsBLEOnly bool - Timestamp time.Time + PersonID string + PersonName string + PersonColor string + Confidence float64 + IsBLEOnly bool + Timestamp time.Time } GetMatches() map[int]interface{} } // IdentityInfo represents identity information for a blob. type IdentityInfo struct { - PersonID string - PersonLabel string - PersonColor string + PersonID string + PersonLabel string + PersonColor string IdentityConfidence float64 - IdentitySource string + IdentitySource string } // IdentityProvider provides identity matching for tracked blobs. diff --git a/mothership/internal/tracker/tracker.go b/mothership/internal/tracker/tracker.go index 87ef259..74ed6bc 100644 --- a/mothership/internal/tracker/tracker.go +++ b/mothership/internal/tracker/tracker.go @@ -34,22 +34,22 @@ func (p Posture) String() string { // Blob is a tracked entity with a persistent numeric identity. type Blob struct { - ID int - X, Y, Z float64 // world-space position, metres - VX, VY, VZ float64 // velocity, m/s - Weight float64 // detection confidence [0..1] - Posture Posture - LastSeen time.Time + ID int + X, Y, Z float64 // world-space position, metres + VX, VY, VZ float64 // velocity, m/s + Weight float64 // detection confidence [0..1] + Posture Posture + LastSeen time.Time // Trail holds the last TrailMaxLen positions (newest last). Trail [][3]float64 // Identity fields (populated by BLE-to-blob matching) - PersonID string `json:"person_id,omitempty"` // UUID from BLE registry - PersonLabel string `json:"person_label,omitempty"` // Display name - PersonColor string `json:"person_color,omitempty"` // Hex color for dashboard - IdentityConfidence float64 `json:"identity_confidence,omitempty"` // Match confidence [0..1] - IdentitySource string `json:"identity_source,omitempty"` // "ble_triangulation", "ble_only", or "" - IdentityLastSeen time.Time `json:"-"` // Last time identity was confirmed + PersonID string `json:"person_id,omitempty"` // UUID from BLE registry + PersonLabel string `json:"person_label,omitempty"` // Display name + PersonColor string `json:"person_color,omitempty"` // Hex color for dashboard + IdentityConfidence float64 `json:"identity_confidence,omitempty"` // Match confidence [0..1] + IdentitySource string `json:"identity_source,omitempty"` // "ble_triangulation", "ble_only", or "" + IdentityLastSeen time.Time `json:"-"` // Last time identity was confirmed ukf *UKF // internal — nil in copies returned to callers } @@ -58,10 +58,10 @@ type Blob struct { const TrailMaxLen = 60 const ( - maxAssocDist = 2.0 // m — measurement-to-track gate radius + maxAssocDist = 2.0 // m — measurement-to-track gate radius gapTolerance = 3 * time.Second // persistence through occlusion - minSeparation = 0.4 // m — collision avoidance floor - walkThreshold = 0.3 // m/s horizontal speed → walking posture + minSeparation = 0.4 // m — collision avoidance floor + walkThreshold = 0.3 // m/s horizontal speed → walking posture ) // Posture height thresholds (Y = blob centroid height above floor, metres). @@ -160,8 +160,8 @@ func (t *Tracker) Update(measurements [][4]float64) []Blob { continue } b := &Blob{ - ID: t.nextID, - X: m[0], Y: m[1], Z: m[2], + ID: t.nextID, + X: m[0], Y: m[1], Z: m[2], Weight: m[3], LastSeen: now, Trail: [][3]float64{{m[0], m[1], m[2]}}, diff --git a/mothership/internal/tracker/tracker_test.go b/mothership/internal/tracker/tracker_test.go index 623b279..884ec37 100644 --- a/mothership/internal/tracker/tracker_test.go +++ b/mothership/internal/tracker/tracker_test.go @@ -10,7 +10,7 @@ import ( func TestSinglePersonTracking(t *testing.T) { tr := NewTracker() - const dt = 0.1 // 10 Hz + const dt = 0.1 // 10 Hz const steps = 50 // Straight walk: x advances at 0.8 m/s, y=1.0 m (standing), z constant. diff --git a/mothership/internal/tracking/tracker.go b/mothership/internal/tracking/tracker.go index 0d775a0..4e30fb9 100644 --- a/mothership/internal/tracking/tracker.go +++ b/mothership/internal/tracking/tracker.go @@ -19,23 +19,23 @@ const ( // Blob represents a tracked person/object in the room. type Blob struct { - ID int - X float64 // metres, room X - Z float64 // metres, room Z - VX float64 // m/s - VZ float64 // m/s - Weight float64 // localisation confidence [0..1] - LastSeen time.Time - Trail [][2]float64 // recent positions (newest last) - ukf *UKF + ID int + X float64 // metres, room X + Z float64 // metres, room Z + VX float64 // m/s + VZ float64 // m/s + Weight float64 // localisation confidence [0..1] + LastSeen time.Time + Trail [][2]float64 // recent positions (newest last) + ukf *UKF // Identity fields (populated by BLE-to-blob matching) - PersonID string `json:"person_id,omitempty"` // UUID from BLE registry - PersonLabel string `json:"person_label,omitempty"` // Display name - PersonColor string `json:"person_color,omitempty"` // Hex color for dashboard - IdentityConfidence float64 `json:"identity_confidence,omitempty"` // Match confidence [0..1] - IdentitySource string `json:"identity_source,omitempty"` // "ble_triangulation", "ble_only", or "" - IdentityLastSeen time.Time `json:"-"` // Last time identity was confirmed + PersonID string `json:"person_id,omitempty"` // UUID from BLE registry + PersonLabel string `json:"person_label,omitempty"` // Display name + PersonColor string `json:"person_color,omitempty"` // Hex color for dashboard + IdentityConfidence float64 `json:"identity_confidence,omitempty"` // Match confidence [0..1] + IdentitySource string `json:"identity_source,omitempty"` // "ble_triangulation", "ble_only", or "" + IdentityLastSeen time.Time `json:"-"` // Last time identity was confirmed Posture Posture `json:"posture,omitempty"` // Estimated body posture } diff --git a/mothership/internal/tracking/ukf.go b/mothership/internal/tracking/ukf.go index e61b35d..d48a0d8 100644 --- a/mothership/internal/tracking/ukf.go +++ b/mothership/internal/tracking/ukf.go @@ -68,10 +68,10 @@ func measureModel(x [stateN]float64) [2]float64 { // UKF is an Unscented Kalman Filter tracking [x, z, vx, vz]. type UKF struct { - X [stateN]float64 // state estimate - P [stateN][stateN]float64 // covariance estimate - Q [stateN][stateN]float64 // process noise - R [2][2]float64 // measurement noise + X [stateN]float64 // state estimate + P [stateN][stateN]float64 // covariance estimate + Q [stateN][stateN]float64 // process noise + R [2][2]float64 // measurement noise } // NewUKF creates a UKF at initial position (x0, z0). diff --git a/mothership/internal/volume/shape.go b/mothership/internal/volume/shape.go index 156aef5..eb2f416 100644 --- a/mothership/internal/volume/shape.go +++ b/mothership/internal/volume/shape.go @@ -183,12 +183,12 @@ type PredictionProvider interface { // Store provides trigger storage and state management. type Store struct { - mu sync.RWMutex - db *sql.DB - triggers map[string]*Trigger - triggerState map[string]*TriggerState // trigger_id -> state - blobVolumes map[int]string // blob_id -> current volume_id (for tracking) - onFired FiringCallback // Called when a trigger fires + mu sync.RWMutex + db *sql.DB + triggers map[string]*Trigger + triggerState map[string]*TriggerState // trigger_id -> state + blobVolumes map[int]string // blob_id -> current volume_id (for tracking) + onFired FiringCallback // Called when a trigger fires predictionProvider PredictionProvider // Provides prediction data for predicted_enter triggers } @@ -593,8 +593,8 @@ func (s *Store) Evaluate(blobs []BlobPos, now time.Time) []string { shouldFire = s.evaluateVacant(t, state, blobs, now) case "count": shouldFire = s.evaluateCount(t, state, blobs, now) - case "predicted_enter": - shouldFire = s.evaluatePredictedEnter(t, state, now) + case "predicted_enter": + shouldFire = s.evaluatePredictedEnter(t, state, now) } if shouldFire { diff --git a/mothership/internal/volume/shape_test.go b/mothership/internal/volume/shape_test.go index 620b83a..7016575 100644 --- a/mothership/internal/volume/shape_test.go +++ b/mothership/internal/volume/shape_test.go @@ -134,7 +134,7 @@ func TestStore_EvaluateEnter(t *testing.T) { H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -195,7 +195,7 @@ func TestStore_EvaluateLeave(t *testing.T) { H: float64Ptr(1), }, Condition: "leave", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -295,7 +295,7 @@ func TestStore_EvaluateDwell(t *testing.T) { } // Blob re-enters and stays for duration threshold - should fire again - reEntry := now.Add(time.Duration(durationSec)*time.Second+10*time.Second) + reEntry := now.Add(time.Duration(durationSec)*time.Second + 10*time.Second) store.Evaluate(blobsInside, reEntry) // enters fired = store.Evaluate(blobsInside, reEntry.Add(time.Duration(durationSec)*time.Second)) if len(fired) != 1 { @@ -317,7 +317,7 @@ func TestStore_EvaluateDwell_Accuracy(t *testing.T) { Shape: ShapeJSON{ Type: ShapeBox, X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), - W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), + W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "dwell", ConditionParams: ConditionParams{ @@ -415,11 +415,11 @@ func TestStore_EvaluateLeave_BlobDisappears(t *testing.T) { Name: "test leave disappear", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "leave", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -454,7 +454,7 @@ func TestStore_EvaluateVacant_Cancelled(t *testing.T) { Name: "test vacant cancelled", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "vacant", @@ -507,11 +507,11 @@ func TestStore_MultipleBlobs(t *testing.T) { Name: "test multi-blob enter", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(4), D: float64Ptr(4), H: float64Ptr(2), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -689,11 +689,11 @@ func TestStore_NilDurationParams(t *testing.T) { Name: "dwell no duration", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "dwell", - Enabled: true, + Enabled: true, } _, err = store.Create(dwellTrigger) @@ -713,11 +713,11 @@ func TestStore_NilDurationParams(t *testing.T) { Name: "count no threshold", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "count", - Enabled: true, + Enabled: true, } _, err = store.Create(countTrigger) @@ -743,11 +743,11 @@ func TestStore_DisabledTrigger(t *testing.T) { Name: "disabled trigger", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: false, + Enabled: false, } _, err = store.Create(trigger) @@ -779,11 +779,11 @@ func TestStore_FiringCallback(t *testing.T) { Name: "callback test", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -830,22 +830,22 @@ func TestStore_BlobVolumeTracking(t *testing.T) { Name: "box1", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } box2 := &Trigger{ Name: "box2", Shape: ShapeJSON{ Type: ShapeBox, - X: float64Ptr(5), Y: float64Ptr(0), Z: float64Ptr(0), + X: float64Ptr(5), Y: float64Ptr(0), Z: float64Ptr(0), W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id1, _ := store.Create(box1) @@ -1027,7 +1027,7 @@ func TestStore_CRUD(t *testing.T) { H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []Action{ {Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}}, }, @@ -1122,7 +1122,7 @@ func TestStore_TimeConstraint(t *testing.T) { // Before time window - blob enters but no fire due to time constraint beforeTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC) - store.Evaluate(blobsOutside, beforeTime) // Blob outside + store.Evaluate(blobsOutside, beforeTime) // Blob outside fired := store.Evaluate(blobsInside, beforeTime) // Blob enters if len(fired) != 0 { t.Errorf("Expected 0 firings (before time window), got %d", len(fired)) @@ -1197,7 +1197,7 @@ func TestStore_ErrorCountManagement(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, Actions: []Action{ {Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}}, }, @@ -1259,7 +1259,7 @@ func TestStore_DisableTriggerWithError(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1306,7 +1306,7 @@ func TestStore_EnableTriggerClearsErrorState(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1352,7 +1352,7 @@ func TestStore_ErrorCountResetsOnFirst2xx(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1393,7 +1393,7 @@ func TestStore_WebhookLogAudit(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1453,7 +1453,7 @@ func TestStore_5xxDoesNotDisable(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1491,7 +1491,7 @@ func TestStore_DisabledTriggerSkippedInEvaluate(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store.Create(trigger) @@ -1529,7 +1529,7 @@ func TestStore_ErrorStatePersistsAcrossRestart(t *testing.T) { W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1), }, Condition: "enter", - Enabled: true, + Enabled: true, } id, err := store1.Create(trigger) diff --git a/mothership/internal/webhook/publisher.go b/mothership/internal/webhook/publisher.go index c1085b8..494ca94 100644 --- a/mothership/internal/webhook/publisher.go +++ b/mothership/internal/webhook/publisher.go @@ -15,10 +15,10 @@ import ( // Config holds webhook configuration. type Config struct { - URL string // Target URL for webhook delivery - Timeout time.Duration // Request timeout (default 5s) - RetryDelay time.Duration // Delay before retry (default 30s) - Enabled bool // Whether webhook is enabled + URL string // Target URL for webhook delivery + Timeout time.Duration // Request timeout (default 5s) + RetryDelay time.Duration // Delay before retry (default 30s) + Enabled bool // Whether webhook is enabled } // EventPayload represents the JSON payload sent to the webhook. @@ -34,10 +34,10 @@ type EventPayload struct { // Publisher publishes all spaxel events to a configured webhook URL. type Publisher struct { - mu sync.RWMutex - config Config - client *http.Client - stopped chan struct{} + mu sync.RWMutex + config Config + client *http.Client + stopped chan struct{} } // NewPublisher creates a new webhook publisher. @@ -50,7 +50,7 @@ func NewPublisher(cfg Config) *Publisher { } return &Publisher{ - config: cfg, + config: cfg, client: &http.Client{ Timeout: cfg.Timeout, }, diff --git a/mothership/internal/zones/manager.go b/mothership/internal/zones/manager.go index db4da52..0087b16 100644 --- a/mothership/internal/zones/manager.go +++ b/mothership/internal/zones/manager.go @@ -27,10 +27,10 @@ const ( type ZoneType string const ( - ZoneTypeNormal ZoneType = "normal" // Default zone - ZoneTypeBedroom ZoneType = "bedroom" // Enables sleep monitoring - ZoneTypeKitchen ZoneType = "kitchen" // No special behavior - ZoneTypeChildren ZoneType = "children" // Suppresses fall detection + ZoneTypeNormal ZoneType = "normal" // Default zone + ZoneTypeBedroom ZoneType = "bedroom" // Enables sleep monitoring + ZoneTypeKitchen ZoneType = "kitchen" // No special behavior + ZoneTypeChildren ZoneType = "children" // Suppresses fall detection ) // Zone represents a spatial region in the room. @@ -38,12 +38,12 @@ type Zone struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` // Hex color for visualization - MinX float64 `json:"min_x"` - MinY float64 `json:"min_y"` - MinZ float64 `json:"min_z"` - MaxX float64 `json:"max_x"` - MaxY float64 `json:"max_y"` - MaxZ float64 `json:"max_z"` + MinX float64 `json:"min_x"` + MinY float64 `json:"min_y"` + MinZ float64 `json:"min_z"` + MaxX float64 `json:"max_x"` + MaxY float64 `json:"max_y"` + MaxZ float64 `json:"max_z"` Enabled bool `json:"enabled"` ZoneType ZoneType `json:"zone_type"` // Zone type for behavior customization IsChildrenZone bool `json:"is_children_zone"` // Suppresses fall detection in this zone (deprecated, use ZoneType) @@ -52,10 +52,10 @@ type Zone struct { // Portal represents a doorway/transition plane between zones. type Portal struct { - ID string `json:"id"` - Name string `json:"name"` - ZoneAID string `json:"zone_a_id"` - ZoneBID string `json:"zone_b_id"` + ID string `json:"id"` + Name string `json:"name"` + ZoneAID string `json:"zone_a_id"` + ZoneBID string `json:"zone_b_id"` // Portal plane definition (3 points defining the doorway plane) P1X float64 `json:"p1_x"` P1Y float64 `json:"p1_y"` @@ -67,25 +67,25 @@ type Portal struct { P3Y float64 `json:"p3_y"` P3Z float64 `json:"p3_z"` // Portal normal vector (computed from points) - NX float64 `json:"n_x"` - NY float64 `json:"n_y"` - NZ float64 `json:"n_z"` - Width float64 `json:"width"` // Portal width in meters - Height float64 `json:"height"` // Portal height in meters - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` + NX float64 `json:"n_x"` + NY float64 `json:"n_y"` + NZ float64 `json:"n_z"` + Width float64 `json:"width"` // Portal width in meters + Height float64 `json:"height"` // Portal height in meters + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` } // CrossingEvent represents a detected portal crossing. type CrossingEvent struct { - ID int64 `json:"id"` // Database row ID - PortalID string `json:"portal_id"` - BlobID int `json:"blob_id"` - Direction int `json:"direction"` // 1 = A->B, -1 = B->A - FromZone string `json:"from_zone"` - ToZone string `json:"to_zone"` - Timestamp time.Time `json:"timestamp"` - Identity string `json:"identity,omitempty"` // Device name if matched + ID int64 `json:"id"` // Database row ID + PortalID string `json:"portal_id"` + BlobID int `json:"blob_id"` + Direction int `json:"direction"` // 1 = A->B, -1 = B->A + FromZone string `json:"from_zone"` + ToZone string `json:"to_zone"` + Timestamp time.Time `json:"timestamp"` + Identity string `json:"identity,omitempty"` // Device name if matched } // ZoneTransitionEvent represents a blob entering or leaving a zone. @@ -108,10 +108,10 @@ type ZoneOccupancy struct { // Manager handles zones, portals, and occupancy. type Manager struct { - mu sync.RWMutex - db *sql.DB - zones map[string]*Zone - portals map[string]*Portal + mu sync.RWMutex + db *sql.DB + zones map[string]*Zone + portals map[string]*Portal // Occupancy tracking occupancy map[string]*ZoneOccupancy @@ -122,7 +122,7 @@ type Manager struct { } // Crossing detection state - blobSide map[int]float64 // blobID -> which side of portal (>0 = A side, <0 = B side) + blobSide map[int]float64 // blobID -> which side of portal (>0 = A side, <0 = B side) // Reconciliation state startedAt time.Time // time this session started @@ -157,19 +157,19 @@ func NewManager(dbPath string, tz *time.Location) (*Manager, error) { } m := &Manager{ - db: db, - zones: make(map[string]*Zone), - portals: make(map[string]*Portal), - occupancy: make(map[string]*ZoneOccupancy), + db: db, + zones: make(map[string]*Zone), + portals: make(map[string]*Portal), + occupancy: make(map[string]*ZoneOccupancy), blobPositions: make(map[int]struct { X, Y, Z float64 ZoneID string LastUpdated time.Time }), - blobSide: make(map[int]float64), - startedAt: time.Now(), - reconciled: false, - tz: tz, + blobSide: make(map[int]float64), + startedAt: time.Now(), + reconciled: false, + tz: tz, } if err := m.migrate(); err != nil { @@ -191,7 +191,6 @@ func NewManager(dbPath string, tz *time.Location) (*Manager, error) { return m, nil } - func (m *Manager) loadZones() error { rows, err := m.db.Query(`SELECT id, name, color, min_x, min_y, min_z, max_x, max_y, max_z, enabled, zone_type, is_children_zone, created_at FROM zones`) if err != nil { @@ -489,7 +488,7 @@ type pendingCrossing struct { // UpdateBlobPositions updates blob positions and detects portal crossings. // Callbacks are fired synchronously after the lock is released to avoid deadlock. func (m *Manager) UpdateBlobPositions(blobs []struct { - ID int + ID int X, Y, Z float64 }) { now := time.Now() @@ -831,7 +830,7 @@ func (m *Manager) GetBlobZone(blobID int) string { // UpdateBlobPosition updates a single blob's position (convenience method). func (m *Manager) UpdateBlobPosition(blobID int, x, y, z float64) { m.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{{ID: blobID, X: x, Y: y, Z: z}}) } diff --git a/mothership/internal/zones/manager_test.go b/mothership/internal/zones/manager_test.go index 30caef9..de3d285 100644 --- a/mothership/internal/zones/manager_test.go +++ b/mothership/internal/zones/manager_test.go @@ -63,19 +63,19 @@ func TestReconcileOccupancy_PersistedOnly(t *testing.T) { { name: "multiple zones with various counts", persisted: map[string]int{ - "kitchen": 1, - "bedroom": 0, - "hallway": 3, + "kitchen": 1, + "bedroom": 0, + "hallway": 3, }, wantCount: map[string]int{ - "kitchen": 1, - "bedroom": 0, - "hallway": 3, + "kitchen": 1, + "bedroom": 0, + "hallway": 3, }, wantStatus: map[string]OccupancyStatus{ - "kitchen": OccupancyUncertain, - "bedroom": OccupancyUncertain, - "hallway": OccupancyUncertain, + "kitchen": OccupancyUncertain, + "bedroom": OccupancyUncertain, + "hallway": OccupancyUncertain, }, }, } @@ -88,8 +88,8 @@ func TestReconcileOccupancy_PersistedOnly(t *testing.T) { // Create zones and set persisted occupancy for zoneID := range tt.persisted { zone := &Zone{ - ID: zoneID, - Name: zoneID, + ID: zoneID, + Name: zoneID, MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1, Enabled: true, @@ -135,22 +135,22 @@ func TestReconcileOccupancy_PersistedOnly(t *testing.T) { func TestReconcileOccupancy_WithCrossings(t *testing.T) { tests := []struct { - name string - persisted map[string]int // zone_id -> last_known_occupancy - crossings []struct { - zoneA string - zoneB string - dir int // 1 = a_to_b, -1 = b_to_a - tsMs int64 + name string + persisted map[string]int // zone_id -> last_known_occupancy + crossings []struct { + zoneA string + zoneB string + dir int // 1 = a_to_b, -1 = b_to_a + tsMs int64 } - wantCount map[string]int - wantStatus map[string]OccupancyStatus + wantCount map[string]int + wantStatus map[string]OccupancyStatus }{ { name: "one person left kitchen after midnight", persisted: map[string]int{ - "kitchen": 2, - "hallway": 0, + "kitchen": 2, + "hallway": 0, }, crossings: []struct { zoneA string @@ -161,19 +161,19 @@ func TestReconcileOccupancy_WithCrossings(t *testing.T) { {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(1 * time.Hour)}, }, wantCount: map[string]int{ - "kitchen": 1, - "hallway": 1, + "kitchen": 1, + "hallway": 1, }, wantStatus: map[string]OccupancyStatus{ - "kitchen": OccupancyUncertain, - "hallway": OccupancyUncertain, + "kitchen": OccupancyUncertain, + "hallway": OccupancyUncertain, }, }, { name: "person entered and left (net zero)", persisted: map[string]int{ - "kitchen": 1, - "hallway": 0, + "kitchen": 1, + "hallway": 0, }, crossings: []struct { zoneA string @@ -185,18 +185,18 @@ func TestReconcileOccupancy_WithCrossings(t *testing.T) { {zoneA: "hallway", zoneB: "kitchen", dir: 1, tsMs: nowMsSinceMidnight(2 * time.Hour)}, }, wantCount: map[string]int{ - "kitchen": 1, - "hallway": 0, + "kitchen": 1, + "hallway": 0, }, wantStatus: map[string]OccupancyStatus{ - "kitchen": OccupancyUncertain, + "kitchen": OccupancyUncertain, }, }, { name: "net negative clamped to zero", persisted: map[string]int{ - "kitchen": 0, - "hallway": 0, + "kitchen": 0, + "hallway": 0, }, crossings: []struct { zoneA string @@ -207,8 +207,8 @@ func TestReconcileOccupancy_WithCrossings(t *testing.T) { {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(1 * time.Hour)}, }, wantCount: map[string]int{ - "kitchen": 0, // clamped from -1 - "hallway": 1, + "kitchen": 0, // clamped from -1 + "hallway": 1, }, wantStatus: map[string]OccupancyStatus{ "hallway": OccupancyUncertain, @@ -217,8 +217,8 @@ func TestReconcileOccupancy_WithCrossings(t *testing.T) { { name: "crossings before midnight ignored", persisted: map[string]int{ - "kitchen": 2, - "hallway": 0, + "kitchen": 2, + "hallway": 0, }, crossings: []struct { zoneA string @@ -230,8 +230,8 @@ func TestReconcileOccupancy_WithCrossings(t *testing.T) { {zoneA: "kitchen", zoneB: "hallway", dir: 1, tsMs: nowMsSinceMidnight(-1 * time.Hour)}, }, wantCount: map[string]int{ - "kitchen": 2, - "hallway": 0, + "kitchen": 2, + "hallway": 0, }, wantStatus: map[string]OccupancyStatus{ "kitchen": OccupancyUncertain, @@ -477,9 +477,9 @@ func TestPersistOccupancy(t *testing.T) { { name: "multiple zones", occupancy: map[string]int{ - "kitchen": 1, - "bedroom": 0, - "hallway": 3, + "kitchen": 1, + "bedroom": 0, + "hallway": 3, }, }, { @@ -547,7 +547,7 @@ func TestPersistOccupancy_OnBlobUpdate(t *testing.T) { // Update blob positions — should persist occupancy m.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{ {ID: 1, X: 5, Y: 5, Z: 1}, @@ -565,7 +565,7 @@ func TestPersistOccupancy_OnBlobUpdate(t *testing.T) { // Add second blob m.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{ {ID: 1, X: 5, Y: 5, Z: 1}, @@ -597,7 +597,7 @@ func TestPersistOccupancy_OnBlobRemoval(t *testing.T) { // Add blobs m.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{ {ID: 1, X: 5, Y: 5, Z: 1}, @@ -652,7 +652,7 @@ func TestEndToEnd_RestoreOccupancyAfterRestart(t *testing.T) { // Simulate 2 people in kitchen m1.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{ {ID: 1, X: 5, Y: 5, Z: 1}, @@ -716,7 +716,7 @@ func TestEndToEnd_RestoreWithCrossings(t *testing.T) { // Simulate 2 people in kitchen, persist m1.UpdateBlobPositions([]struct { - ID int + ID int X, Y, Z float64 }{ {ID: 1, X: 5, Y: 5, Z: 1}, @@ -806,12 +806,12 @@ func TestIsReconciled_NoZones(t *testing.T) { func TestCrossingDetection_PlaneCrossing(t *testing.T) { tests := []struct { - name string - portal Portal - prevPos struct{ X, Y, Z float64 } - currPos struct{ X, Y, Z float64 } - wantCrossing bool - wantDirection int // 1 = A->B, -1 = B->A + name string + portal Portal + prevPos struct{ X, Y, Z float64 } + currPos struct{ X, Y, Z float64 } + wantCrossing bool + wantDirection int // 1 = A->B, -1 = B->A }{ { name: "cross from A side to B side", @@ -823,12 +823,12 @@ func TestCrossingDetection_PlaneCrossing(t *testing.T) { P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, // Normal pointing -X (A side is +X) + NX: -1, NY: 0, NZ: 0, // Normal pointing -X (A side is +X) Enabled: true, }, - prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side - currPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side - wantCrossing: true, + prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side + currPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side + wantCrossing: true, wantDirection: 1, // A->B }, { @@ -841,12 +841,12 @@ func TestCrossingDetection_PlaneCrossing(t *testing.T) { P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, Enabled: true, }, - prevPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side - currPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side - wantCrossing: true, + prevPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side + currPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side + wantCrossing: true, wantDirection: -1, // B->A }, { @@ -855,15 +855,15 @@ func TestCrossingDetection_PlaneCrossing(t *testing.T) { ID: "portal_3", ZoneAID: "kitchen", ZoneBID: "hallway", - P1X: 5, P1Y: 0, P1Z: 0, + P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, Enabled: true, }, - prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side - currPos: struct{ X, Y, Z float64 }{X: 7, Y: 1, Z: 0.5}, // Still A side - wantCrossing: false, + prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side + currPos: struct{ X, Y, Z float64 }{X: 7, Y: 1, Z: 0.5}, // Still A side + wantCrossing: false, wantDirection: 0, }, { @@ -876,12 +876,12 @@ func TestCrossingDetection_PlaneCrossing(t *testing.T) { P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, Enabled: true, }, - prevPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 0, Z: 0}, // Just on B side - currPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 2, Z: 1}, // Still B side, moved in YZ - wantCrossing: false, + prevPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 0, Z: 0}, // Just on B side + currPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 2, Z: 1}, // Still B side, moved in YZ + wantCrossing: false, wantDirection: 0, }, } @@ -977,10 +977,10 @@ func TestCrossingDetection_ParallelMovementWithinTolerance(t *testing.T) { ID: "portal_1", ZoneAID: "kitchen", ZoneBID: "hallway", - P1X: 5, P1Y: 0, P1Z: 0, + P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, } m.CreatePortal(portal) @@ -1043,7 +1043,7 @@ func TestCrossingDetection_OutsideWidthBounds(t *testing.T) { P1X: 5, P1Y: 0, P1Z: 2, P2X: 5, P2Y: 2.1, P2Z: 2, P3X: 5, P3Y: 0, P3Z: 3, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, } m.CreatePortal(portal) @@ -1089,10 +1089,10 @@ func TestOccupancyCount_WithPortalCrossing(t *testing.T) { Name: "Kitchen-LR Door", ZoneAID: "kitchen", ZoneBID: "living_room", - P1X: 5, P1Y: 0, P1Z: 0, + P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, Enabled: true, } m.CreatePortal(portal) @@ -1284,10 +1284,10 @@ func TestZoneTransitionWebSocket_Broadcast(t *testing.T) { Name: "Kitchen-LR Door", ZoneAID: "kitchen", ZoneBID: "living_room", - P1X: 5, P1Y: 0, P1Z: 0, + P1X: 5, P1Y: 0, P1Z: 0, P2X: 5, P2Y: 2, P2Z: 0, P3X: 5, P3Y: 0, P3Z: 1, - NX: -1, NY: 0, NZ: 0, + NX: -1, NY: 0, NZ: 0, Enabled: true, } m.CreatePortal(portal) @@ -1366,11 +1366,11 @@ func TestZoneTransitionWebSocket_Broadcast(t *testing.T) { func TestGetPortalCrossings(t *testing.T) { tests := []struct { - name string - crossings []struct { + name string + crossings []struct { portalID string blobID int - dir int // 1 = A->B, -1 = B->A + dir int // 1 = A->B, -1 = B->A fromZone string toZone string tsMs int64 @@ -1577,14 +1577,14 @@ func TestGetZoneHistory(t *testing.T) { } tests := []struct { - name string - history []struct { + name string + history []struct { hourTs int64 // hour bucket timestamp count int // occupancy count people []string // people list (will be JSON-serialized) } hours int - wantCounts []int // expected counts per hour bucket (newest to oldest) + wantCounts []int // expected counts per hour bucket (newest to oldest) wantPeople [][]string // expected people per hour bucket (newest to oldest) }{ { diff --git a/mothership/test/acceptance/as1_first_time_setup_test.go b/mothership/test/acceptance/as1_first_time_setup_test.go index b8489db..d217094 100644 --- a/mothership/test/acceptance/as1_first_time_setup_test.go +++ b/mothership/test/acceptance/as1_first_time_setup_test.go @@ -122,13 +122,13 @@ func AS1_NoManualIPRequired(t *testing.T) { // Verify the provisioning API endpoint exists and can generate a provisioning payload without requiring an IP. provisioningResponse := map[string]interface{}{ - "wifi_ssid": "TestNetwork", - "wifi_pass": "testpass", - "node_id": "test-node-uuid", - "node_token": "test-token-hex", - "ms_mdns": "spaxel", - "ms_port": 8080, - "debug": false, + "wifi_ssid": "TestNetwork", + "wifi_pass": "testpass", + "node_id": "test-node-uuid", + "node_token": "test-token-hex", + "ms_mdns": "spaxel", + "ms_port": 8080, + "debug": false, } // Verify the provisioning response contains mDNS service name diff --git a/mothership/test/acceptance/as2_walking_detection_test.go b/mothership/test/acceptance/as2_walking_detection_test.go index 2deead8..0ab26d3 100644 --- a/mothership/test/acceptance/as2_walking_detection_test.go +++ b/mothership/test/acceptance/as2_walking_detection_test.go @@ -409,7 +409,7 @@ func AS2_SmoothDeltaRMSAboveThreshold(t *testing.T) { diagnosticsResponse := map[string]interface{}{ "links": []map[string]interface{}{ { - "link_id": "AA:BB:CC:DD:EE:FF:AA:BB:CC:DD:EE:F0", + "link_id": "AA:BB:CC:DD:EE:FF:AA:BB:CC:DD:EE:F0", "delta_rms": 0.08, "smooth_delta_rms": 0.07, "threshold": 0.02, diff --git a/mothership/test/acceptance/as3_fall_detection_test.go b/mothership/test/acceptance/as3_fall_detection_test.go index ef50749..23979f3 100644 --- a/mothership/test/acceptance/as3_fall_detection_test.go +++ b/mothership/test/acceptance/as3_fall_detection_test.go @@ -285,12 +285,12 @@ func startMockMothershipForFallDetection(t *testing.T, webhookURL string) *httpt // Return fall_alert events events := []map[string]interface{}{ { - "id": 1, - "type": "fall_alert", - "timestamp_ms": time.Now().UnixMilli(), - "blob_id": 1, - "detail_json": `{"start_z":1.7,"end_z":0.3,"peak_velocity":-2.5}`, - "severity": "alert", + "id": 1, + "type": "fall_alert", + "timestamp_ms": time.Now().UnixMilli(), + "blob_id": 1, + "detail_json": `{"start_z":1.7,"end_z":0.3,"peak_velocity":-2.5}`, + "severity": "alert", }, } json.NewEncoder(w).Encode(map[string]interface{}{ @@ -300,8 +300,8 @@ func startMockMothershipForFallDetection(t *testing.T, webhookURL string) *httpt case "/api/zones": zones := []map[string]interface{}{ { - "id": 1, - "name": "Bedroom", + "id": 1, + "name": "Bedroom", "zone_type": "bedroom", }, } @@ -402,12 +402,12 @@ func simulateFallSequence(t *testing.T) []blobState { now := time.Now() return []blobState{ - {Time: now.Add(-2 * time.Second), Z: 1.7, VZ: 0.0}, // Standing + {Time: now.Add(-2 * time.Second), Z: 1.7, VZ: 0.0}, // Standing {Time: now.Add(-1500 * time.Millisecond), Z: 1.65, VZ: -0.5}, // Starting to fall - {Time: now.Add(-1 * time.Second), Z: 1.4, VZ: -1.8}, // Falling - {Time: now.Add(-500 * time.Millisecond), Z: 0.8, VZ: -2.5}, // Rapid descent - {Time: now, Z: 0.3, VZ: -0.5}, // Near floor - {Time: now.Add(500 * time.Millisecond), Z: 0.3, VZ: 0.0}, // On floor + {Time: now.Add(-1 * time.Second), Z: 1.4, VZ: -1.8}, // Falling + {Time: now.Add(-500 * time.Millisecond), Z: 0.8, VZ: -2.5}, // Rapid descent + {Time: now, Z: 0.3, VZ: -0.5}, // Near floor + {Time: now.Add(500 * time.Millisecond), Z: 0.3, VZ: 0.0}, // On floor } } diff --git a/mothership/test/acceptance/as4_ble_identity_test.go b/mothership/test/acceptance/as4_ble_identity_test.go index 272c3ab..834ad27 100644 --- a/mothership/test/acceptance/as4_ble_identity_test.go +++ b/mothership/test/acceptance/as4_ble_identity_test.go @@ -40,7 +40,7 @@ func AS4_BLEIdentityResolvesToPersonName(t *testing.T) { t.Run("RegisterBLEDevice", func(t *testing.T) { // Register Alice's phone aliceDevice := map[string]interface{}{ - "addr": "AA:BB:CC:DD:EE:FF", + "addr": "AA:BB:CC:DD:EE:FF", "label": "Alice", "type": "person", "color": "#4488ff", @@ -63,14 +63,14 @@ func AS4_BLEIdentityResolvesToPersonName(t *testing.T) { t.Run("SimulateBLEScanWithBlob", func(t *testing.T) { // Send BLE scan result bleScan := map[string]interface{}{ - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F0", // Node MAC + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F0", // Node MAC "timestamp_ms": time.Now().UnixMilli(), "devices": []map[string]interface{}{ { - "addr": "AA:BB:CC:DD:EE:FF", - "rssi": -60, - "name": "Alice's iPhone", + "addr": "AA:BB:CC:DD:EE:FF", + "rssi": -60, + "name": "Alice's iPhone", }, }, } @@ -220,8 +220,8 @@ func AS4_BLEIdentityPersistence(t *testing.T) { // Send initial BLE scan bleScan := map[string]interface{}{ - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F0", + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F0", "timestamp_ms": time.Now().UnixMilli(), "devices": []map[string]interface{}{ { @@ -241,7 +241,7 @@ func AS4_BLEIdentityPersistence(t *testing.T) { if person, ok := blob["person"].(string); ok && person == "Bob" { bobFound = true break - } + } } if !bobFound { @@ -267,8 +267,8 @@ func AS4_BLEIdentityPersistence(t *testing.T) { // Simulate address rotation (new MAC for same device) rotatedScan := map[string]interface{}{ - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F0", + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F0", "timestamp_ms": time.Now().UnixMilli(), "devices": []map[string]interface{}{ { @@ -316,8 +316,8 @@ func AS4_RSSIBasedPositionMatching(t *testing.T) { // Send BLE scans from multiple nodes with different RSSI bleScans := []map[string]interface{}{ { - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F0", // Node 1 + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F0", // Node 1 "timestamp_ms": time.Now().UnixMilli(), "devices": []map[string]interface{}{ { @@ -327,8 +327,8 @@ func AS4_RSSIBasedPositionMatching(t *testing.T) { }, }, { - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F1", // Node 2 + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F1", // Node 2 "timestamp_ms": time.Now().Add(100 * time.Millisecond).UnixMilli(), "devices": []map[string]interface{}{ { @@ -384,8 +384,8 @@ func AS4_UnregisteredDeviceIgnored(t *testing.T) { // Send BLE scan for unregistered device bleScan := map[string]interface{}{ - "type": "ble", - "mac": "AA:BB:CC:DD:EE:F0", + "type": "ble", + "mac": "AA:BB:CC:DD:EE:F0", "timestamp_ms": time.Now().UnixMilli(), "devices": []map[string]interface{}{ { @@ -429,10 +429,10 @@ func startMockMothershipForBLE(t *testing.T) *httptest.Server { // Return list of registered devices devices := []map[string]interface{}{ { - "addr": "AA:BB:CC:DD:EE:FF", - "label": "Alice", - "type": "person", - "color": "#4488ff", + "addr": "AA:BB:CC:DD:EE:FF", + "label": "Alice", + "type": "person", + "color": "#4488ff", }, } json.NewEncoder(w).Encode(devices) @@ -442,11 +442,11 @@ func startMockMothershipForBLE(t *testing.T) *httptest.Server { // Return blobs with identity blobs := []map[string]interface{}{ { - "id": 1, - "x": 1.5, - "y": 2.0, - "z": 1.0, - "person": "Alice", // Identity assigned + "id": 1, + "x": 1.5, + "y": 2.0, + "z": 1.0, + "person": "Alice", // Identity assigned "confidence": 0.9, }, } @@ -506,7 +506,7 @@ func AS4_Integration(t *testing.T) { "addr": "AA:BB:CC:DD:EE:FF", "label": "Alice", "type": "person", - "color": "#4488ff", + "color": "#4488ff", } body, _ := json.Marshal(aliceDevice) @@ -587,4 +587,3 @@ func AS4_Integration(t *testing.T) { t.Log("AS-4 Integration test PASSED") } - diff --git a/mothership/test/acceptance/as5_ota_test.go b/mothership/test/acceptance/as5_ota_test.go index 6cd1fac..1f3c1be 100644 --- a/mothership/test/acceptance/as5_ota_test.go +++ b/mothership/test/acceptance/as5_ota_test.go @@ -67,11 +67,11 @@ func AS5_OTAUpdateSucceeds(t *testing.T) { t.Run("NodeAppliesUpdate", func(t *testing.T) { // Register node for OTA node := map[string]interface{}{ - "mac": "AA:BB:CC:DD:EE:FF", - "name": "TestNode", - "role": "tx_rx", - "version": "v1.2.2", - "platform": "esp32s3", + "mac": "AA:BB:CC:DD:EE:FF", + "name": "TestNode", + "role": "tx_rx", + "version": "v1.2.2", + "platform": "esp32s3", } body, _ := json.Marshal(node) @@ -83,10 +83,10 @@ func AS5_OTAUpdateSucceeds(t *testing.T) { // Request OTA update otaRequest := map[string]interface{}{ - "node_mac": "AA:BB:CC:DD:EE:FF", - "version": "v1.2.3", - "url": srv.URL + "/api/firmware/spaxel-nodemcu-v1.2.3.bin", - "checksum": "abc123def456", + "node_mac": "AA:BB:CC:DD:EE:FF", + "version": "v1.2.3", + "url": srv.URL + "/api/firmware/spaxel-nodemcu-v1.2.3.bin", + "checksum": "abc123def456", } otaBody, _ := json.Marshal(otaRequest) @@ -168,10 +168,10 @@ func AS5_RollbackOnBootFailure(t *testing.T) { t.Run("BadFirmwareTriggersRollback", func(t *testing.T) { // Register node node := map[string]interface{}{ - "mac": "AA:BB:CC:DD:EF:00", - "name": "BadFirmwareNode", - "version": "v1.2.3", - "platform": "esp32s3", + "mac": "AA:BB:CC:DD:EF:00", + "name": "BadFirmwareNode", + "version": "v1.2.3", + "platform": "esp32s3", } body, _ := json.Marshal(node) @@ -183,10 +183,10 @@ func AS5_RollbackOnBootFailure(t *testing.T) { // Request OTA with bad firmware otaRequest := map[string]interface{}{ - "node_mac": "AA:BB:CC:DD:EF:00", - "version": "v1.2.4-bad", - "url": srv.URL + "/api/firmware/spaxel-nodemcu-v1.2.4-bad.bin", - "checksum": "badchecksum123", + "node_mac": "AA:BB:CC:DD:EF:00", + "version": "v1.2.4-bad", + "url": srv.URL + "/api/firmware/spaxel-nodemcu-v1.2.4-bad.bin", + "checksum": "badchecksum123", } otaBody, _ := json.Marshal(otaRequest) @@ -390,8 +390,8 @@ func startMockMothershipForOTA(t *testing.T) *httptest.Server { if from != "" && to != "" { // Differential query json.NewEncoder(w).Encode(map[string]interface{}{ - "has_differential": true, - "differential_size": 524288.0, // 512KB + "has_differential": true, + "differential_size": 524288.0, // 512KB "full_size": 1048576.0, // 1MB "from_version": from, "to_version": to, diff --git a/mothership/test/acceptance/as6_replay_test.go b/mothership/test/acceptance/as6_replay_test.go index 32b368d..3f4a2f5 100644 --- a/mothership/test/acceptance/as6_replay_test.go +++ b/mothership/test/acceptance/as6_replay_test.go @@ -122,8 +122,8 @@ func AS6_SeekPerformance(t *testing.T) { // Test seeks to various points in the 48-hour window testSeeks := []struct { - name string - offsetMS float64 // Offset from buffer start in milliseconds + name string + offsetMS float64 // Offset from buffer start in milliseconds }{ {"Seek to start", 0}, {"Seek to 1 hour", 3600 * 1000}, @@ -488,7 +488,7 @@ func AS6_BackToLiveResumesDetection(t *testing.T) { sessionID, _ := session["id"].(string) // Seek to some point in history - seekBody := map[string]interface{}{"target_ms": time.Now().Add(-1*time.Hour).UnixMilli()} + seekBody := map[string]interface{}{"target_ms": time.Now().Add(-1 * time.Hour).UnixMilli()} body, _ := json.Marshal(seekBody) http.Post(srv.URL+"/api/replay/session/"+sessionID+"/seek", "application/json", bytes.NewReader(body)) @@ -553,7 +553,7 @@ func AS6_ReplayIsolation(t *testing.T) { sessionID, _ := session["id"].(string) // Seek back in time - seekBody := map[string]interface{}{"target_ms": time.Now().Add(-2*time.Hour).UnixMilli()} + seekBody := map[string]interface{}{"target_ms": time.Now().Add(-2 * time.Hour).UnixMilli()} body, _ := json.Marshal(seekBody) http.Post(srv.URL+"/api/replay/session/"+sessionID+"/seek", "application/json", bytes.NewReader(body)) @@ -599,7 +599,7 @@ func startMockMothershipForReplay(t *testing.T) *httptest.Server { // Create new replay session now := time.Now() session := map[string]interface{}{ - "id": "test-session-" + now.Format("150405"), + "id": "test-session-" + now.Format("150405"), "buffer_start_ms": now.Add(-48 * time.Hour).UnixMilli(), "buffer_end_ms": now.UnixMilli(), "current_ms": now.Add(-1 * time.Hour).UnixMilli(), @@ -610,7 +610,7 @@ func startMockMothershipForReplay(t *testing.T) *httptest.Server { // Get current session now := time.Now() session := map[string]interface{}{ - "id": "test-session", + "id": "test-session", "buffer_start_ms": now.Add(-48 * time.Hour).UnixMilli(), "buffer_end_ms": now.UnixMilli(), "current_ms": now.Add(-1 * time.Hour).UnixMilli(), @@ -627,11 +627,11 @@ func startMockMothershipForReplay(t *testing.T) *httptest.Server { "events": []map[string]interface{}{ { "type": "zone_entry", - "timestamp_ms": now.Add(-2*time.Hour).UnixMilli(), + "timestamp_ms": now.Add(-2 * time.Hour).UnixMilli(), }, { "type": "anomaly", - "timestamp_ms": now.Add(-4*time.Hour).UnixMilli(), + "timestamp_ms": now.Add(-4 * time.Hour).UnixMilli(), }, }, }