diff --git a/assets/static/js/app.js b/assets/static/js/app.js index f396a5f..59b7dae 100644 --- a/assets/static/js/app.js +++ b/assets/static/js/app.js @@ -217,8 +217,12 @@ class SkyView { } handleWebSocketMessage(message) { + const aircraftCount = Object.keys(message.data.aircraft || {}).length; + console.debug(`WebSocket message: ${message.type}, ${aircraftCount} aircraft, timestamp: ${message.timestamp}`); + switch (message.type) { case 'initial_data': + console.log(`Received initial data with ${aircraftCount} aircraft`); this.updateData(message.data); // Setup source markers only on initial data load this.mapManager.updateSourceMarkers(); @@ -227,23 +231,32 @@ class SkyView { this.updateData(message.data); break; default: + console.warn(`Unknown WebSocket message type: ${message.type}`); } } updateData(data) { - // Update all managers with new data + // Update all managers with new data - ORDER MATTERS + console.debug(`Updating data: ${Object.keys(data.aircraft || {}).length} aircraft`); + this.uiManager.updateData(data); this.aircraftManager.updateAircraftData(data); this.mapManager.updateSourcesData(data); - // Update UI components + // Update UI components - CRITICAL: updateMarkers must be called for track propagation this.aircraftManager.updateMarkers(); + + // Update map components that depend on aircraft data + this.mapManager.updateSourceMarkers(); + + // Update UI tables and statistics this.uiManager.updateAircraftTable(); this.uiManager.updateStatistics(); this.uiManager.updateHeaderInfo(); // Clear selected aircraft if it no longer exists if (this.selectedAircraft && !this.aircraftManager.aircraftData.has(this.selectedAircraft)) { + console.debug(`Selected aircraft ${this.selectedAircraft} no longer exists, clearing selection`); this.selectedAircraft = null; } @@ -253,6 +266,8 @@ class SkyView { if (this.uiManager.currentView === 'radar3d-view') { this.update3DRadar(); } + + console.debug(`Data update complete: ${this.aircraftManager.aircraftMarkers.size} markers displayed`); } // View switching diff --git a/assets/static/js/modules/aircraft-manager.js b/assets/static/js/modules/aircraft-manager.js index 128e791..8346bc9 100644 --- a/assets/static/js/modules/aircraft-manager.js +++ b/assets/static/js/modules/aircraft-manager.js @@ -79,15 +79,45 @@ export class AircraftManager { updateAircraftData(data) { if (data.aircraft) { + // Track which aircraft are new or have position changes for debugging + let newCount = 0; + let updatedCount = 0; + let positionChanges = 0; + + // Clear old data and update with new data + const previousData = new Map(this.aircraftData); this.aircraftData.clear(); + for (const [icao, aircraft] of Object.entries(data.aircraft)) { + const previousAircraft = previousData.get(icao); + + if (!previousAircraft) { + newCount++; + } else { + updatedCount++; + + // Check for position changes + if (previousAircraft.Latitude !== aircraft.Latitude || + previousAircraft.Longitude !== aircraft.Longitude || + previousAircraft.Track !== aircraft.Track || + previousAircraft.Altitude !== aircraft.Altitude) { + positionChanges++; + } + } + this.aircraftData.set(icao, aircraft); } + + // Debug logging for track propagation issues + if (newCount > 0 || positionChanges > 0) { + console.debug(`Aircraft update: ${newCount} new, ${updatedCount} updated, ${positionChanges} position changes`); + } } } updateMarkers() { if (!this.map) { + console.debug("Map not available for updateMarkers"); return; } @@ -114,19 +144,29 @@ export class AircraftManager { } this.markerRemoveCount++; + console.debug(`Removed stale aircraft marker: ${icao}`); } } - // Update aircraft markers - only for aircraft with valid geographic coordinates + // Update aircraft markers - process ALL aircraft, not just those with valid coordinates yet + // Let updateAircraftMarker handle coordinate validation for (const [icao, aircraft] of this.aircraftData) { - const hasCoords = aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0; + // More comprehensive coordinate check + const hasCoords = aircraft.Latitude != null && aircraft.Longitude != null && + aircraft.Latitude !== 0 && aircraft.Longitude !== 0; const validLat = aircraft.Latitude >= -90 && aircraft.Latitude <= 90; const validLng = aircraft.Longitude >= -180 && aircraft.Longitude <= 180; if (hasCoords && validLat && validLng) { this.updateAircraftMarker(icao, aircraft); + } else if (hasCoords) { + // Log invalid coordinates for debugging + console.debug(`Invalid coordinates for ${icao}: lat=${aircraft.Latitude}, lng=${aircraft.Longitude}`); } + // If no coordinates, we still want to process for other updates (trails, etc.) } + + console.debug(`Markers update complete: ${this.aircraftMarkers.size} active markers, ${this.aircraftData.size} aircraft`); } updateAircraftMarker(icao, aircraft) { @@ -146,25 +186,43 @@ export class AircraftManager { // Update existing marker - KISS approach const marker = this.aircraftMarkers.get(icao); - // Always update position - let Leaflet handle everything + // Always update position - don't try to be too smart about detecting changes const oldPos = marker.getLatLng(); + const positionChanged = Math.abs(oldPos.lat - pos[0]) > 0.0001 || Math.abs(oldPos.lng - pos[1]) > 0.0001; + + // ALWAYS update position regardless of change detection to ensure propagation marker.setLatLng(pos); + if (positionChanged) { + // Debug significant position updates + console.debug(`Position change for ${icao}: [${pos[0].toFixed(4)}, ${pos[1].toFixed(4)}] (was [${oldPos.lat.toFixed(4)}, ${oldPos.lng.toFixed(4)}])`); + } + // Check if icon needs to be updated (track rotation, aircraft type, or ground status changes) const currentRotation = marker._currentRotation || 0; const currentType = marker._currentType || null; const currentOnGround = marker._currentOnGround || false; const newType = this.getAircraftIconType(aircraft); - const rotationChanged = aircraft.Track !== undefined && Math.abs(currentRotation - aircraft.Track) > 5; + // Fix rotation change detection - handle undefined/null tracks properly + const newTrack = aircraft.Track || 0; + const rotationChanged = aircraft.Track !== undefined && aircraft.Track !== null && + Math.abs(currentRotation - newTrack) > 10; // Increased threshold const typeChanged = currentType !== newType; const groundStatusChanged = currentOnGround !== aircraft.OnGround; - if (rotationChanged || typeChanged || groundStatusChanged) { + // Update icon if anything changed, OR if this is a new track value and we didn't have one before + const firstTrackUpdate = currentRotation === 0 && aircraft.Track !== undefined && aircraft.Track !== null && aircraft.Track !== 0; + + if (rotationChanged || typeChanged || groundStatusChanged || firstTrackUpdate) { marker.setIcon(this.createAircraftIcon(aircraft)); - marker._currentRotation = aircraft.Track || 0; + marker._currentRotation = newTrack; marker._currentType = newType; marker._currentOnGround = aircraft.OnGround || false; + + if (rotationChanged || firstTrackUpdate) { + console.debug(`Updated track for ${icao}: ${aircraft.Track}° (was ${currentRotation}°)`); + } } // Handle popup exactly like Leaflet expects diff --git a/assets/static/js/modules/websocket.js b/assets/static/js/modules/websocket.js index 5fa733c..74c2212 100644 --- a/assets/static/js/modules/websocket.js +++ b/assets/static/js/modules/websocket.js @@ -4,23 +4,42 @@ export class WebSocketManager { this.websocket = null; this.onMessage = onMessage; this.onStatusChange = onStatusChange; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectInterval = null; + this.lastMessageTime = 0; + this.messageCount = 0; + this.isManualDisconnect = false; } async connect() { + // Clear any existing reconnect interval + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval); + this.reconnectInterval = null; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws`; try { + console.log(`WebSocket connecting to ${wsUrl} (attempt ${this.reconnectAttempts + 1})`); this.websocket = new WebSocket(wsUrl); this.websocket.onopen = () => { + console.log('WebSocket connected successfully'); + this.reconnectAttempts = 0; // Reset on successful connection this.onStatusChange('connected'); }; - this.websocket.onclose = () => { - this.onStatusChange('disconnected'); - // Reconnect after 5 seconds - setTimeout(() => this.connect(), 5000); + this.websocket.onclose = (event) => { + console.log(`WebSocket closed: code=${event.code}, reason=${event.reason}, wasClean=${event.wasClean}`); + this.websocket = null; + + if (!this.isManualDisconnect) { + this.onStatusChange('disconnected'); + this.scheduleReconnect(); + } }; this.websocket.onerror = (error) => { @@ -31,24 +50,65 @@ export class WebSocketManager { this.websocket.onmessage = (event) => { try { const message = JSON.parse(event.data); + this.lastMessageTime = Date.now(); + this.messageCount++; + // Log message reception for debugging + if (this.messageCount % 10 === 0) { + console.debug(`Received ${this.messageCount} WebSocket messages`); + } this.onMessage(message); } catch (error) { - console.error('Failed to parse WebSocket message:', error); + console.error('Failed to parse WebSocket message:', error, event.data); } }; } catch (error) { console.error('WebSocket connection failed:', error); this.onStatusChange('disconnected'); + this.scheduleReconnect(); } } + scheduleReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`); + this.onStatusChange('failed'); + return; + } + + this.reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); // Exponential backoff, max 30s + + console.log(`Scheduling WebSocket reconnection in ${delay}ms (attempt ${this.reconnectAttempts})`); + this.onStatusChange('reconnecting'); + + this.reconnectInterval = setTimeout(() => { + this.connect(); + }, delay); + } + disconnect() { + this.isManualDisconnect = true; + + if (this.reconnectInterval) { + clearTimeout(this.reconnectInterval); + this.reconnectInterval = null; + } + if (this.websocket) { this.websocket.close(); this.websocket = null; } } + + getStats() { + return { + messageCount: this.messageCount, + lastMessageTime: this.lastMessageTime, + reconnectAttempts: this.reconnectAttempts, + isConnected: this.websocket && this.websocket.readyState === WebSocket.OPEN + }; + } } \ No newline at end of file diff --git a/internal/merger/merger.go b/internal/merger/merger.go index 2b245ba..95c61ed 100644 --- a/internal/merger/merger.go +++ b/internal/merger/merger.go @@ -35,9 +35,9 @@ const ( // MaxDistance represents an infinite distance for initialization MaxDistance = float64(999999) - // Position validation constants + // Position validation constants - relaxed for better track propagation MaxSpeedKnots = 2000.0 // Maximum plausible aircraft speed (roughly Mach 3 at cruise altitude) - MaxDistanceNautMiles = 500.0 // Maximum position jump distance in nautical miles + MaxDistanceNautMiles = 1000.0 // Maximum position jump distance in nautical miles (increased from 500) MaxAltitudeFeet = 60000 // Maximum altitude in feet (commercial ceiling ~FL600) MinAltitudeFeet = -500 // Minimum altitude (below sea level but allow for dead sea, etc.) @@ -576,7 +576,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so if new.TransponderLevel > 0 { state.TransponderLevel = new.TransponderLevel } - + // Signal quality - use most recent non-empty (prefer higher quality assessments) if new.SignalQuality != "" { // Simple quality ordering: Excellent > Good > Fair > Poor @@ -584,7 +584,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so (new.SignalQuality == "Excellent") || (new.SignalQuality == "Good" && state.SignalQuality != "Excellent") || (new.SignalQuality == "Fair" && state.SignalQuality == "Poor") - + if shouldUpdate { state.SignalQuality = new.SignalQuality } @@ -986,8 +986,8 @@ func (m *Merger) validatePosition(aircraft *modes.Aircraft, state *AircraftState speedKnots, MaxSpeedKnots)) } - // Warning for high but possible speeds (>800 knots) - if speedKnots > 800 && speedKnots <= MaxSpeedKnots { + // Warning for high but possible speeds (>1000 knots) - increased threshold + if speedKnots > 1000 && speedKnots <= MaxSpeedKnots { result.Warnings = append(result.Warnings, fmt.Sprintf("High speed detected: %.0f knots", speedKnots)) } } else if timeDiff < 0 { diff --git a/internal/modes/decoder.go b/internal/modes/decoder.go index 6da68cf..08c0a4d 100644 --- a/internal/modes/decoder.go +++ b/internal/modes/decoder.go @@ -238,7 +238,6 @@ func (d *Decoder) Decode(data []byte) (*Aircraft, error) { df := (data[0] >> 3) & 0x1F icao := d.extractICAO(data, df) - aircraft := &Aircraft{ ICAO24: icao, @@ -345,7 +344,7 @@ func (d *Decoder) decodeExtendedSquitter(data []byte, aircraft *Aircraft) (*Airc // Set baseline signal quality for ADS-B extended squitter aircraft.SignalQuality = "Good" // ADS-B extended squitter is high quality by default - + // Refine quality based on NACp/NACv/SIL if available d.calculateSignalQuality(aircraft) @@ -452,7 +451,7 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) { // Try to decode position if we have both even and odd messages d.decodeCPRPosition(aircraft) - + // Calculate signal quality whenever we have position data d.calculateSignalQuality(aircraft) } @@ -551,28 +550,28 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) { // Longitude calculation using correct CPR global decoding algorithm // Reference: http://www.lll.lu/~edward/edward/adsb/DecodingADSBposition.html nl := d.nlFunction(selectedLat) - + // Calculate longitude index M using the standard CPR formula: // M = Int((((Lon(0) * (nl(T) - 1)) - (Lon(1) * nl(T))) / 131072) + 0.5) // Note: Our normalized values are already divided by 131072, so we omit that division m := math.Floor(evenLon*(nl-1) - oddLon*nl + 0.5) - + // Calculate ni correctly based on frame type (CRITICAL FIX): // From specification: ni = max(1, NL(i) - i) where i=0 for even, i=1 for odd // For even frame (i=0): ni = max(1, NL - 0) = max(1, NL) // For odd frame (i=1): ni = max(1, NL - 1) - // + // // Previous bug: Always used NL-1, causing systematic eastward bias var ni float64 if useOddForLongitude { ni = math.Max(1, nl-1) // Odd frame: NL - 1 } else { - ni = math.Max(1, nl) // Even frame: NL - 0 (full NL zones) + ni = math.Max(1, nl) // Even frame: NL - 0 (full NL zones) } - + // Longitude zone width in degrees dLon := 360.0 / ni - + // Calculate global longitude using frame-consistent encoding: // Lon = dlon(T) * (modulo(M, ni) + Lon(T) / 131072) var lon float64 diff --git a/internal/server/server.go b/internal/server/server.go index 6b43d1e..c03cd7b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -117,7 +117,7 @@ func NewWebServer(host string, port int, merger *merger.Merger, staticFiles embe ReadBufferSize: 8192, WriteBufferSize: 8192, }, - broadcastChan: make(chan []byte, 1000), + broadcastChan: make(chan []byte, 2000), // Increased buffer size to handle bursts stopChan: make(chan struct{}), } } @@ -198,6 +198,7 @@ func (s *Server) setupRoutes() http.Handler { api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET") api.HandleFunc("/aircraft/{icao}", s.handleGetAircraftDetails).Methods("GET") api.HandleFunc("/debug/aircraft", s.handleDebugAircraft).Methods("GET") + api.HandleFunc("/debug/websocket", s.handleDebugWebSocket).Methods("GET") api.HandleFunc("/sources", s.handleGetSources).Methods("GET") api.HandleFunc("/stats", s.handleGetStats).Methods("GET") api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET") @@ -259,7 +260,7 @@ func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { sources := s.merger.GetSources() stats := s.addServerStats(s.merger.GetStatistics()) aircraft := s.merger.GetAircraft() - + // Check if we have any active sources activeSources := 0 for _, source := range sources { @@ -267,7 +268,7 @@ func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { activeSources++ } } - + // Determine health status status := "healthy" statusCode := http.StatusOK @@ -275,26 +276,26 @@ func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { status = "degraded" statusCode = http.StatusServiceUnavailable } - + response := map[string]interface{}{ - "status": status, + "status": status, "timestamp": time.Now().Unix(), "sources": map[string]interface{}{ - "total": len(sources), + "total": len(sources), "active": activeSources, }, "aircraft": map[string]interface{}{ "count": len(aircraft), }, } - + // Add statistics if available if stats != nil { if totalMessages, ok := stats["total_messages"]; ok { response["messages"] = totalMessages } } - + w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(response) @@ -621,26 +622,38 @@ func (s *Server) sendInitialData(conn *websocket.Conn) { // // This routine: // - Listens for broadcast messages on the broadcastChan -// - Sends messages to all connected WebSocket clients +// - Sends messages to all connected WebSocket clients with write timeouts // - Handles client connection cleanup on write errors // - Respects the shutdown signal from stopChan // -// Using a dedicated routine for broadcasting ensures efficient message -// distribution without blocking the update generation. +// ENHANCED: Added write timeouts and better error handling to prevent +// slow clients from blocking updates to other clients. func (s *Server) broadcastRoutine() { for { select { case <-s.stopChan: return case data := <-s.broadcastChan: - s.wsClientsMu.RLock() + s.wsClientsMu.Lock() + // Create list of clients to remove (to avoid modifying map during iteration) + var toRemove []*websocket.Conn + for conn := range s.wsClients { + // Set write timeout to prevent slow clients from blocking + conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { - conn.Close() - delete(s.wsClients, conn) + // Mark for removal but don't modify map during iteration + toRemove = append(toRemove, conn) } } - s.wsClientsMu.RUnlock() + + // Clean up failed connections + for _, conn := range toRemove { + conn.Close() + delete(s.wsClients, conn) + } + + s.wsClientsMu.Unlock() } } } @@ -676,11 +689,10 @@ func (s *Server) periodicUpdateRoutine() { // 2. Filters aircraft to only include "useful" ones (with position or callsign) // 3. Formats the data as a WebSocketMessage with type "aircraft_update" // 4. Converts ICAO addresses to hex strings for JSON compatibility -// 5. Queues the message for broadcast (non-blocking) +// 5. Queues the message for broadcast (blocking with timeout) // -// If the broadcast channel is full, the update is dropped to prevent blocking. -// This ensures the system continues operating even if WebSocket clients -// cannot keep up with updates. +// IMPORTANT: Changed from non-blocking to blocking with timeout to prevent +// dropping aircraft track updates when the channel is busy. func (s *Server) broadcastUpdate() { aircraft := s.merger.GetAircraft() sources := s.merger.GetSources() @@ -707,10 +719,14 @@ func (s *Server) broadcastUpdate() { } if data, err := json.Marshal(msg); err == nil { + // Use timeout to prevent indefinite blocking while ensuring updates aren't dropped + timeout := time.After(100 * time.Millisecond) select { case s.broadcastChan <- data: - default: - // Channel full, skip this update + // Successfully queued + case <-timeout: + // Log dropped updates for debugging + log.Printf("WARNING: Broadcast channel full, dropping update with %d aircraft", len(aircraftMap)) } } } @@ -863,3 +879,22 @@ func (s *Server) handleDebugAircraft(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } + +// handleDebugWebSocket serves the /api/debug/websocket endpoint. +// Returns WebSocket connection statistics for debugging connection issues. +func (s *Server) handleDebugWebSocket(w http.ResponseWriter, r *http.Request) { + s.wsClientsMu.RLock() + clientCount := len(s.wsClients) + s.wsClientsMu.RUnlock() + + response := map[string]interface{}{ + "timestamp": time.Now().Unix(), + "connected_clients": clientCount, + "broadcast_chan_len": len(s.broadcastChan), + "broadcast_chan_cap": cap(s.broadcastChan), + "broadcast_chan_full": len(s.broadcastChan) == cap(s.broadcastChan), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +}