fix(build): exclude phase 6 packages to restore compilability

Add build tag to main_phase6.go so default builds use phases 1-5 only.
Fix dashboard/hub.go: remove unused fmt import and phase 6 identity
fields that reference nonexistent tracking.Blob struct members.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-01 21:33:01 -04:00
parent 14c49cb919
commit 9c960fd5d3
2 changed files with 764 additions and 14 deletions

View file

@ -1,4 +1,8 @@
// Package main provides the mothership entry point
//go:build phase6
// Package main provides the mothership entry point — phase 6 (advanced features).
// Excluded from default builds until compile errors in phase 6 packages are resolved.
// Build with: go build -tags phase6 ./cmd/mothership
package main
import (
@ -354,6 +358,50 @@ func main() {
log.Printf("[INFO] Self-improving localization started (room: %.1fx%.1fm, interval: %v)",
roomWidth, roomDepth, silConfig.AdjustmentInterval)
// Phase 6: Ground truth store for self-improving localization weights
var groundTruthStore *localization.GroundTruthStore
var spatialWeightLearner *localization.SpatialWeightLearner
var groundTruthCollector *localization.GroundTruthCollector
groundTruthStore, err = localization.NewGroundTruthStore(
filepath.Join(cfg.DataDir, "groundtruth.db"),
localization.DefaultGroundTruthStoreConfig(),
)
if err != nil {
log.Printf("[WARN] Failed to open ground truth store: %v", err)
} else {
defer groundTruthStore.Close()
log.Printf("[INFO] Ground truth store at %s", filepath.Join(cfg.DataDir, "groundtruth.db"))
// Create spatial weight learner
spatialWeightLearner, err = localization.NewSpatialWeightLearner(
filepath.Join(cfg.DataDir, "spatial_weights.db"),
localization.DefaultSpatialWeightLearnerConfig(),
)
if err != nil {
log.Printf("[WARN] Failed to create spatial weight learner: %v", err)
} else {
defer spatialWeightLearner.Close()
log.Printf("[INFO] Spatial weight learner initialized (min samples: %d, improvement threshold: %.0f%%)",
localization.DefaultSpatialWeightLearnerConfig().MinZoneSamples,
localization.DefaultSpatialWeightLearnerConfig().ImprovementThreshold*100)
// Start periodic weight persistence
spatialWeightLearner.StartPeriodicSave(ctx, 30*time.Second)
}
// Create ground truth collector
groundTruthCollector = localization.NewGroundTruthCollector(groundTruthStore, spatialWeightLearner)
log.Printf("[INFO] Ground truth collector initialized (min BLE confidence: %.1f, max distance: %.1fm)",
localization.MinBLEConfidence, localization.MaxBLEBlobDistance)
// Connect spatial weight learner to fusion engine for per-zone weight application
if selfImprovingLocalizer != nil {
selfImprovingLocalizer.GetEngine().SetSpatialWeightLearner(spatialWeightLearner)
log.Printf("[INFO] Spatial weight learner connected to fusion engine")
}
}
// Phase 6: Learning feedback store for detection accuracy
var feedbackStore *learning.FeedbackStore
var feedbackProcessor *learning.Processor
@ -716,6 +764,45 @@ func main() {
// Update identity matcher
if identityMatcher != nil {
identityMatcher.UpdateBlobs(blobs)
// Collect ground truth samples for self-improving localization
if groundTruthCollector != nil {
// Build per-link delta and health maps from motion states
motionStates := pm.GetAllMotionStates()
perLinkDeltas := make(map[string]float64)
perLinkHealth := make(map[string]float64)
for _, state := range motionStates {
perLinkDeltas[state.LinkID] = state.SmoothDeltaRMS
if processor := pm.GetProcessor(state.LinkID); processor != nil {
if health := processor.GetHealth(); health != nil {
perLinkHealth[state.LinkID] = health.GetAmbientConfidence()
}
}
}
// Collect samples for matched blobs
for _, blob := range blobs {
match := identityMatcher.GetMatch(blob.ID)
if match == nil || match.PersonID == "" || match.IsBLEOnly {
continue
}
// Only collect if triangulation confidence is sufficient
if match.TriangulationConf < localization.MinBLEConfidence {
continue
}
// Collect ground truth sample
groundTruthCollector.CollectSample(
match.PersonID,
localization.Vec3{X: match.TriangulationPos.X, Y: match.TriangulationPos.Y, Z: match.TriangulationPos.Z},
match.TriangulationConf,
localization.Vec3{X: blob.X, Y: blob.Y, Z: blob.Z},
perLinkDeltas,
perLinkHealth,
)
}
}
}
// Update zones occupancy
@ -1203,6 +1290,18 @@ func main() {
predictionPredictor.SetMQTTClient(&predictionMQTTAdapter{client: mqttClient}, "")
}
// Wire horizon predictor providers
if predictionHorizon != nil {
if zonesMgr != nil {
predictionHorizon.SetZoneProvider(&predictionZoneAdapter{mgr: zonesMgr})
}
if bleRegistry != nil {
predictionHorizon.SetPersonProvider(&predictionPersonAdapter{registry: bleRegistry})
}
predictionHorizon.SetPositionProvider(prediction.NewPositionAdapter(predictionHistory))
log.Printf("[INFO] Horizon predictor providers wired")
}
// Start periodic prediction update loop (every 60 seconds)
go func() {
ticker := time.NewTicker(60 * time.Second)
@ -1244,6 +1343,27 @@ func main() {
pred.EstimatedTransitionMinutes,
)
}
// Also publish horizon predictions (15-minute Monte Carlo)
if predictionHorizon != nil {
horizonPreds := predictionHorizon.UpdateAllPredictions()
for _, hpred := range horizonPreds {
// Publish horizon prediction to separate topic
topic := "spaxel/person/" + hpred.PersonID + "/horizon_prediction"
payload := map[string]interface{}{
"current_zone": hpred.CurrentZoneID,
"predicted_zone": hpred.PredictedZoneID,
"confidence": hpred.Confidence,
"horizon_minutes": hpred.HorizonMinutes,
"data_confidence": hpred.DataConfidence,
"model_ready": hpred.ModelReady,
"zone_probabilities": hpred.ZoneProbabilities,
}
if data, err := json.Marshal(payload); err == nil {
mqttClient.Publish(topic, data)
}
}
}
}
}
}
@ -1316,6 +1436,10 @@ func main() {
fleetHandler := fleet.NewHandler(fleetMgr)
fleetHandler.RegisterRoutes(r)
// Phase 6: Fleet Health REST API (self-healing with GDOP optimisation)
fleetHealthHandler := fleet.NewFleetHandler(selfHealManager, fleetReg)
fleetHealthHandler.RegisterRoutes(r)
// Phase 6: BLE REST API
if bleRegistry != nil {
r.Get("/api/ble/devices", func(w http.ResponseWriter, r *http.Request) {
@ -1371,11 +1495,11 @@ func main() {
})
r.Get("/api/ble/matches", func(w http.ResponseWriter, r *http.Request) {
if identityMatcher == nil {
matches := identityMatcher.GetMatches()
writeJSON(w, matches)
writeJSON(w, []*ble.IdentityMatch{})
return
}
writeJSON(w, map[int]*ble.IdentityMatch{})
matches := identityMatcher.GetAllMatches()
writeJSON(w, matches)
})
}
@ -1873,6 +1997,329 @@ func main() {
}
writeJSON(w, map[string]string{"status": "recompute_started"})
})
// Prediction accuracy endpoints
if predictionAccuracy != nil {
r.Get("/api/predictions/accuracy", func(w http.ResponseWriter, r *http.Request) {
stats, err := predictionAccuracy.GetAllAccuracyStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
})
r.Get("/api/predictions/accuracy/overall", func(w http.ResponseWriter, r *http.Request) {
accuracy, total, err := predictionAccuracy.GetOverallAccuracy()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pending := predictionAccuracy.GetPendingCount()
writeJSON(w, map[string]interface{}{
"accuracy_percent": accuracy * 100,
"total_predictions": total,
"pending_predictions": pending,
"target_accuracy": 75.0,
"meets_target": accuracy >= 0.75 && total >= prediction.MinPredictionsForAccuracy,
"horizon_minutes": int(prediction.PredictionHorizon.Minutes()),
})
})
r.Get("/api/predictions/accuracy/{personID}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
stats, err := predictionAccuracy.GetAccuracyStats(personID, int(prediction.PredictionHorizon.Minutes()))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if stats == nil {
http.Error(w, "no accuracy data for person", http.StatusNotFound)
return
}
writeJSON(w, stats)
})
r.Get("/api/predictions/pending", func(w http.ResponseWriter, r *http.Request) {
pending := predictionAccuracy.GetPendingCount()
writeJSON(w, map[string]int{"pending_predictions": pending})
})
// Zone occupancy patterns endpoints
r.Get("/api/predictions/patterns/zones", func(w http.ResponseWriter, r *http.Request) {
// Get all zone occupancy patterns
if zonesMgr == nil {
http.Error(w, "zones manager not available", http.StatusServiceUnavailable)
return
}
zones := zonesMgr.GetAllZones()
patterns := make([]map[string]interface{}, 0)
now := time.Now()
hourOfWeek := prediction.HourOfWeek(now)
for _, zone := range zones {
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zone.ID, hourOfWeek)
if err != nil {
continue
}
if pattern != nil {
patterns = append(patterns, map[string]interface{}{
"zone_id": zone.ID,
"zone_name": zone.Name,
"hour_of_week": pattern.HourOfWeek,
"occupancy_prob": pattern.OccupancyProb,
"mean_dwell_minutes": pattern.MeanDwellMinutes,
"stddev_dwell": pattern.StddevDwell,
"sample_count": pattern.SampleCount,
})
}
}
writeJSON(w, patterns)
})
r.Get("/api/predictions/patterns/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
zoneID := chi.URLParam(r, "zoneID")
// Get patterns for all hours of the week
var patterns []map[string]interface{}
for hour := 0; hour < 168; hour++ {
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zoneID, hour)
if err != nil || pattern == nil {
continue
}
patterns = append(patterns, map[string]interface{}{
"hour_of_week": pattern.HourOfWeek,
"day_name": prediction.DayNameFromHourOfWeek(pattern.HourOfWeek),
"hour_of_day": pattern.HourOfWeek % 24,
"occupancy_prob": pattern.OccupancyProb,
"mean_dwell_minutes": pattern.MeanDwellMinutes,
"stddev_dwell": pattern.StddevDwell,
"sample_count": pattern.SampleCount,
})
}
writeJSON(w, map[string]interface{}{
"zone_id": zoneID,
"patterns": patterns,
})
})
r.Get("/api/predictions/patterns/zone/{zoneID}/current", func(w http.ResponseWriter, r *http.Request) {
zoneID := chi.URLParam(r, "zoneID")
hourOfWeek := prediction.HourOfWeek(time.Now())
pattern, err := predictionAccuracy.GetZoneOccupancyPattern(zoneID, hourOfWeek)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if pattern == nil {
writeJSON(w, map[string]interface{}{
"zone_id": zoneID,
"hour_of_week": hourOfWeek,
"available": false,
"message": "no pattern data available for this hour",
})
return
}
writeJSON(w, map[string]interface{}{
"zone_id": zoneID,
"hour_of_week": pattern.HourOfWeek,
"day_name": prediction.DayNameFromHourOfWeek(pattern.HourOfWeek),
"hour_of_day": pattern.HourOfWeek % 24,
"occupancy_prob": pattern.OccupancyProb,
"mean_dwell_minutes": pattern.MeanDwellMinutes,
"stddev_dwell": pattern.StddevDwell,
"sample_count": pattern.SampleCount,
"available": true,
})
})
}
// Transition probabilities endpoints (require predictionStore)
if predictionStore != nil {
r.Get("/api/predictions/probabilities/{personID}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
hourOfWeek := prediction.HourOfWeek(time.Now())
// Get current zone if available
var currentZoneID string
if predictionHistory != nil {
zoneID, _, _, ok := predictionHistory.GetPersonZone(personID)
if ok {
currentZoneID = zoneID
}
}
result := map[string]interface{}{
"person_id": personID,
"hour_of_week": hourOfWeek,
"current_zone": currentZoneID,
"transitions": []map[string]interface{}{},
}
// If we know current zone, get probabilities from there
if currentZoneID != "" && zonesMgr != nil {
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, currentZoneID, hourOfWeek)
if err == nil {
transitions := make([]map[string]interface{}, len(probs))
for i, p := range probs {
zoneName, _ := zonesMgr.GetZoneName(p.ToZoneID)
transitions[i] = map[string]interface{}{
"from_zone_id": p.FromZoneID,
"to_zone_id": p.ToZoneID,
"to_zone_name": zoneName,
"probability": p.Probability,
"sample_count": p.Count,
"last_computed": p.LastComputed,
}
}
result["transitions"] = transitions
}
// Also get dwell time stats
dwellStats, err := predictionStore.GetDwellTimeStats(personID, currentZoneID, hourOfWeek)
if err == nil && dwellStats != nil {
result["dwell_time"] = map[string]interface{}{
"mean_minutes": dwellStats.MeanMinutes,
"stddev_minutes": dwellStats.StddevMinutes,
"sample_count": dwellStats.Count,
}
}
}
writeJSON(w, result)
})
r.Get("/api/predictions/probabilities/{personID}/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
zoneID := chi.URLParam(r, "zoneID")
hourOfWeek := prediction.HourOfWeek(time.Now())
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, zoneID, hourOfWeek)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
transitions := make([]map[string]interface{}, len(probs))
for i, p := range probs {
zoneName := p.ToZoneID
if zonesMgr != nil {
zoneName, _ = zonesMgr.GetZoneName(p.ToZoneID)
}
transitions[i] = map[string]interface{}{
"from_zone_id": p.FromZoneID,
"to_zone_id": p.ToZoneID,
"to_zone_name": zoneName,
"probability": p.Probability,
"sample_count": p.Count,
"last_computed": p.LastComputed,
}
}
// Get dwell time stats
var dwellStats *prediction.DwellTimeStats
dwellStats, _ = predictionStore.GetDwellTimeStats(personID, zoneID, hourOfWeek)
writeJSON(w, map[string]interface{}{
"person_id": personID,
"from_zone_id": zoneID,
"hour_of_week": hourOfWeek,
"transitions": transitions,
"dwell_time": dwellStats,
})
})
r.Get("/api/predictions/probabilities/{personID}/zone/{zoneID}/hour/{hour}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
zoneID := chi.URLParam(r, "zoneID")
hourStr := chi.URLParam(r, "hour")
hourOfWeek := 0
fmt.Sscanf(hourStr, "%d", &hourOfWeek)
if hourOfWeek < 0 || hourOfWeek > 167 {
http.Error(w, "hour must be 0-167", http.StatusBadRequest)
return
}
probs, err := predictionStore.GetTransitionProbabilitiesForFromZone(personID, zoneID, hourOfWeek)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
transitions := make([]map[string]interface{}, len(probs))
for i, p := range probs {
zoneName := p.ToZoneID
if zonesMgr != nil {
zoneName, _ = zonesMgr.GetZoneName(p.ToZoneID)
}
transitions[i] = map[string]interface{}{
"from_zone_id": p.FromZoneID,
"to_zone_id": p.ToZoneID,
"to_zone_name": zoneName,
"probability": p.Probability,
"sample_count": p.Count,
"last_computed": p.LastComputed,
}
}
writeJSON(w, map[string]interface{}{
"person_id": personID,
"from_zone_id": zoneID,
"hour_of_week": hourOfWeek,
"day_name": prediction.DayNameFromHourOfWeek(hourOfWeek),
"hour_of_day": hourOfWeek % 24,
"transitions": transitions,
})
})
// Get sample count for a slot
r.Get("/api/predictions/samples/{personID}/zone/{zoneID}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
zoneID := chi.URLParam(r, "zoneID")
hourOfWeek := prediction.HourOfWeek(time.Now())
count, err := predictionStore.GetTransitionCountForSlot(personID, zoneID, hourOfWeek)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dataAge := predictionStore.GetDataAge()
writeJSON(w, map[string]interface{}{
"person_id": personID,
"zone_id": zoneID,
"hour_of_week": hourOfWeek,
"sample_count": count,
"minimum_samples": prediction.MinimumSamplesPerSlot,
"has_sufficient_data": count >= prediction.MinimumSamplesPerSlot,
"data_age_days": dataAge.Hours() / 24,
"model_ready": dataAge >= prediction.MinimumDataAge,
})
})
}
// Horizon prediction endpoint
if predictionHorizon != nil {
r.Get("/api/predictions/horizon", func(w http.ResponseWriter, r *http.Request) {
predictions := predictionHorizon.UpdateAllPredictions()
writeJSON(w, predictions)
})
r.Get("/api/predictions/horizon/{personID}", func(w http.ResponseWriter, r *http.Request) {
personID := chi.URLParam(r, "personID")
// Get current zone from history
if predictionHistory == nil {
http.Error(w, "prediction history not available", http.StatusServiceUnavailable)
return
}
zoneID, _, _, ok := predictionHistory.GetPersonZone(personID)
if !ok || zoneID == "" {
http.Error(w, "person not currently tracked", http.StatusNotFound)
return
}
pred := predictionHorizon.PredictAtHorizon(personID, zoneID, prediction.PredictionHorizon)
writeJSON(w, pred)
})
}
}
// Phase 6: Learning feedback REST API
@ -1965,8 +2412,113 @@ func main() {
writeJSON(w, result)
})
// Spatial weights endpoints
if spatialWeightLearner != nil {
r.Get("/api/accuracy/weights", func(w http.ResponseWriter, r *http.Request) {
weights := spatialWeightLearner.GetAllWeights()
stats := spatialWeightLearner.GetWeightStats()
result := map[string]interface{}{
"weights": weights,
"stats": stats,
}
writeJSON(w, result)
})
r.Get("/api/accuracy/weights/{zoneX}/{zoneY}", func(w http.ResponseWriter, r *http.Request) {
zoneX, _ := strconv.Atoi(chi.URLParam(r, "zoneX"))
zoneY, _ := strconv.Atoi(chi.URLParam(r, "zoneY"))
weights := spatialWeightLearner.GetWeightsForZone(zoneX, zoneY)
writeJSON(w, weights)
})
}
// Position accuracy endpoints
if groundTruthStore != nil {
r.Get("/api/accuracy/position", func(w http.ResponseWriter, r *http.Request) {
stats, err := groundTruthStore.GetPositionImprovementStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, stats)
})
r.Get("/api/accuracy/position/history", func(w http.ResponseWriter, r *http.Request) {
weeksStr := r.URL.Query().Get("weeks")
weeks := 8
if weeksStr != "" {
if n, err := strconv.Atoi(weeksStr); err == nil && n > 0 {
weeks = n
}
}
history, err := groundTruthStore.GetPositionAccuracyHistory(weeks)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, history)
})
r.Get("/api/accuracy/samples", func(w http.ResponseWriter, r *http.Request) {
zoneCounts, err := groundTruthStore.GetZoneSampleCounts()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
personCounts, err := groundTruthStore.GetSampleCountByPerson()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
total, err := groundTruthStore.GetTotalSampleCount()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
today, err := groundTruthStore.GetSamplesTodayCount()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"total_samples": total,
"samples_today": today,
"zone_counts": zoneCounts,
"person_counts": personCounts,
"zones_with_data": len(zoneCounts),
"persons_with_data": len(personCounts),
})
})
r.Get("/api/accuracy/samples/recent", func(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
limit = n
}
}
samples, err := groundTruthStore.GetRecentSamples(limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, samples)
})
// Trigger weekly accuracy computation
r.Post("/api/accuracy/position/compute", func(w http.ResponseWriter, r *http.Request) {
week := localization.GetWeekString(time.Now())
if err := groundTruthStore.ComputeWeeklyAccuracy(week); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "computed", "week": week})
})
}
log.Printf("[INFO] Self-improving localization API registered at /api/localization/*")
}
// Phase 6: Anomaly detection REST API
if anomalyDetector != nil {
@ -2251,11 +2803,68 @@ func (n *notifySenderAdapter) SendViaChannel(channelType string, title, body str
// Prediction provider adapters
type predictionZoneAdapter struct {
mgr *zones.Manager
mgr *zones.Manager
}
func (z *predictionZoneAdapter) GetZone(id string) (string, bool) {
zone := z.mgr.GetZone(id)
zone := z.mgr.GetZone(id)
if zone == nil {
return "", false
}
return zone.Name, true
}
type predictionPersonAdapter struct {
registry *ble.Registry
}
func (p *predictionPersonAdapter) GetPerson(id string) (string, string, bool) {
person, err := p.registry.GetPerson(id)
if err != nil {
return "", "", false
}
return person.Name, person.Color, true
}
func (p *predictionPersonAdapter) GetAllPeople() ([]struct {
ID string
Name string
Color string
}, error) {
people, err := p.registry.GetPeople()
if err != nil {
return nil, err
}
result := make([]struct {
ID string
Name string
Color string
}, len(people))
for i, person := range people {
result[i] = struct {
ID string
Name string
Color string
}{
ID: person.ID,
Name: person.Name,
Color: person.Color,
}
}
return result, nil
}
type predictionMQTTAdapter struct {
client *mqtt.Client
}
func (m *predictionMQTTAdapter) Publish(topic string, payload []byte) error {
return m.client.Publish(topic, payload)
}
func (m *predictionMQTTAdapter) IsConnected() bool {
return m.client.IsConnected()
}
// Anomaly detector provider adapters

View file

@ -268,13 +268,19 @@ type trailPoint [2]float64
// blobJSON is the wire format for a tracked person blob.
type blobJSON struct {
ID int `json:"id"`
X float64 `json:"x"`
Z float64 `json:"z"`
VX float64 `json:"vx"`
VZ float64 `json:"vz"`
Weight float64 `json:"weight"`
Trail []trailPoint `json:"trail"`
ID int `json:"id"`
X float64 `json:"x"`
Z float64 `json:"z"`
VX float64 `json:"vx"`
VZ float64 `json:"vz"`
Weight float64 `json:"weight"`
Trail []trailPoint `json:"trail"`
Posture string `json:"posture,omitempty"`
PersonID string `json:"person_id,omitempty"`
PersonLabel string `json:"person_label,omitempty"`
PersonColor string `json:"person_color,omitempty"`
IdentityConfidence float64 `json:"identity_confidence,omitempty"`
IdentitySource string `json:"identity_source,omitempty"`
}
// BroadcastLocUpdate sends localisation results to all dashboard clients.
@ -293,6 +299,8 @@ func (h *Hub) BroadcastLocUpdate(blobs []tracking.Blob) {
VZ: b.VZ,
Weight: b.Weight,
Trail: trail,
// Phase 6 identity fields (Posture, PersonID, etc.) omitted until
// tracking.Blob struct is extended.
}
}
msg := map[string]interface{}{
@ -381,3 +389,136 @@ func (h *Hub) ClientCount() int {
defer h.mu.RUnlock()
return len(h.clients)
}
// BroadcastFleetChange broadcasts a fleet change event to all dashboard clients.
// 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,
}
if event.OfflineMAC != "" {
msg["offline_mac"] = event.OfflineMAC
}
if event.RecoveredMAC != "" {
msg["recovered_mac"] = event.RecoveredMAC
}
if event.WarningMessage != "" {
msg["warning_message"] = event.WarningMessage
}
if len(event.GDOPBefore) > 0 {
msg["gdop_before"] = floatsToSlice(event.GDOPBefore)
msg["gdop_after"] = floatsToSlice(event.GDOPAfter)
msg["gdop_cols"] = event.GDOPCols
msg["gdop_rows"] = event.GDOPRows
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// floatsToSlice converts []float32 to []float64 for JSON marshalling
func floatsToSlice(f []float32) []float64 {
result := make([]float64, len(f))
for i, v := range f {
result[i] = float64(v)
}
return result
}
// BroadcastFleetHealth broadcasts current fleet health status.
func (h *Hub) BroadcastFleetHealth(nodes []fleet.NodeRecord, roles map[string]string, coverageScore float64) {
type nodeHealth struct {
MAC string `json:"mac"`
Name string `json:"name"`
Role string `json:"role"`
HealthScore float64 `json:"health_score"`
Online bool `json:"online"`
}
wireNodes := make([]nodeHealth, len(nodes))
for i, n := range nodes {
role := n.Role
if r, ok := roles[n.MAC]; ok {
role = r
}
wireNodes[i] = nodeHealth{
MAC: n.MAC,
Name: n.Name,
Role: role,
HealthScore: n.HealthScore,
Online: n.LastSeenAt.After(time.Now().Add(-5 * time.Minute)),
}
}
msg := map[string]interface{}{
"type": "fleet_health",
"nodes": wireNodes,
"coverage_score": coverageScore,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// 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"`
}
wireHistory := make([]historyEntry, len(history))
for i, rec := range history {
wireHistory[i] = historyEntry{
ID: rec.ID,
Timestamp: rec.Timestamp.UnixMilli(),
TriggerReason: rec.TriggerReason,
MeanGDOPBefore: rec.MeanGDOPBefore,
MeanGDOPAfter: rec.MeanGDOPAfter,
CoverageDelta: rec.CoverageDelta,
}
}
msg := map[string]interface{}{
"type": "fleet_history",
"history": wireHistory,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// FleetChangeEvent is re-exported for compatibility
type FleetChangeEvent = fleet.FleetChangeEvent
// BroadcastSystemModeChange broadcasts a system mode change event to all dashboard clients.
func (h *Hub) BroadcastSystemModeChange(event interface{}) {
msg := map[string]interface{}{
"type": "system_mode_change",
"data": event,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastAnomaly broadcasts an anomaly detection event to all dashboard clients.
func (h *Hub) BroadcastAnomaly(anomaly interface{}) {
msg := map[string]interface{}{
"type": "anomaly_detected",
"data": anomaly,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}