Fix aircraft track propagation issues in web frontend

This commit addresses issue #23 where aircraft track changes were not
propagating properly to the web frontend. The fixes include:

**Server-side improvements:**
- Enhanced WebSocket broadcast reliability with timeout-based queueing
- Increased broadcast channel buffer size (1000 -> 2000)
- Improved error handling and connection management
- Added write timeouts to prevent slow clients from blocking updates
- Enhanced connection cleanup and ping/pong handling
- Added debug endpoint /api/debug/websocket for troubleshooting
- Relaxed position validation thresholds for better track acceptance

**Frontend improvements:**
- Enhanced WebSocket manager with exponential backoff reconnection
- Improved aircraft position update detection and logging
- Fixed position update logic to always propagate changes to map
- Better coordinate validation and error reporting
- Enhanced debugging with detailed console logging
- Fixed track rotation update thresholds and logic
- Improved marker lifecycle management and cleanup
- Better handling of edge cases in aircraft state transitions

**Key bug fixes:**
- Removed overly aggressive position change detection that blocked updates
- Fixed track rotation sensitivity (5° -> 10° threshold)
- Enhanced coordinate validation to handle null/undefined values
- Improved WebSocket message ordering and processing
- Fixed marker position updates to always propagate to Leaflet

These changes ensure reliable real-time aircraft tracking with proper
position, heading, and altitude updates across multiple data sources.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-25 10:14:03 +02:00
commit 1fe15c06a3
6 changed files with 216 additions and 49 deletions

View file

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

View file

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

View file

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