diff --git a/assets/static/index.html b/assets/static/index.html index 036a832..40ae4e3 100644 --- a/assets/static/index.html +++ b/assets/static/index.html @@ -222,6 +222,6 @@ - + \ No newline at end of file diff --git a/assets/static/js/app-new.js b/assets/static/js/app-new.js new file mode 100644 index 0000000..8800296 --- /dev/null +++ b/assets/static/js/app-new.js @@ -0,0 +1,391 @@ +// Import Three.js modules +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +// Import our modular components +import { WebSocketManager } from './modules/websocket.js?v=2'; +import { AircraftManager } from './modules/aircraft-manager.js?v=2'; +import { MapManager } from './modules/map-manager.js?v=2'; +import { UIManager } from './modules/ui-manager.js?v=2'; + +class SkyView { + constructor() { + console.log('šŸš€ SkyView v2 - KISS approach loaded'); + // Initialize managers + this.wsManager = null; + this.aircraftManager = null; + this.mapManager = null; + this.uiManager = null; + + // 3D Radar + this.radar3d = null; + + // Charts + this.charts = {}; + + this.init(); + } + + async init() { + try { + console.log('Initializing SkyView application...'); + + // Initialize UI manager first + this.uiManager = new UIManager(); + this.uiManager.initializeViews(); + this.uiManager.initializeEventListeners(); + + // Initialize map manager and get the main map + this.mapManager = new MapManager(); + const map = await this.mapManager.initializeMap(); + + // Initialize aircraft manager with the map + this.aircraftManager = new AircraftManager(map); + + // Initialize WebSocket with callbacks + this.wsManager = new WebSocketManager( + (message) => this.handleWebSocketMessage(message), + (status) => this.uiManager.updateConnectionStatus(status) + ); + + await this.wsManager.connect(); + + // Initialize other components + this.initializeCharts(); + this.uiManager.updateClocks(); + this.initialize3DRadar(); + + // Set up map controls + this.setupMapControls(); + + // Set up aircraft selection listener + this.setupAircraftSelection(); + + this.startPeriodicTasks(); + + console.log('SkyView application initialized successfully'); + } catch (error) { + console.error('Initialization failed:', error); + this.uiManager.showError('Failed to initialize application'); + } + } + + setupMapControls() { + const centerMapBtn = document.getElementById('center-map'); + const resetMapBtn = document.getElementById('reset-map'); + const toggleTrailsBtn = document.getElementById('toggle-trails'); + const toggleRangeBtn = document.getElementById('toggle-range'); + const toggleSourcesBtn = document.getElementById('toggle-sources'); + + if (centerMapBtn) { + centerMapBtn.addEventListener('click', () => this.aircraftManager.centerMapOnAircraft()); + } + + if (resetMapBtn) { + resetMapBtn.addEventListener('click', () => this.mapManager.resetMap()); + } + + if (toggleTrailsBtn) { + toggleTrailsBtn.addEventListener('click', () => { + const showTrails = this.aircraftManager.toggleTrails(); + toggleTrailsBtn.textContent = showTrails ? 'Hide Trails' : 'Show Trails'; + }); + } + + if (toggleRangeBtn) { + toggleRangeBtn.addEventListener('click', () => { + const showRange = this.mapManager.toggleRangeCircles(); + toggleRangeBtn.textContent = showRange ? 'Hide Range' : 'Show Range'; + }); + } + + if (toggleSourcesBtn) { + toggleSourcesBtn.addEventListener('click', () => { + const showSources = this.mapManager.toggleSources(); + toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources'; + }); + } + + // Coverage controls + const toggleHeatmapBtn = document.getElementById('toggle-heatmap'); + const coverageSourceSelect = document.getElementById('coverage-source'); + + if (toggleHeatmapBtn) { + toggleHeatmapBtn.addEventListener('click', async () => { + const isActive = await this.mapManager.toggleHeatmap(); + toggleHeatmapBtn.textContent = isActive ? 'Hide Heatmap' : 'Show Heatmap'; + }); + } + + if (coverageSourceSelect) { + coverageSourceSelect.addEventListener('change', (e) => { + this.mapManager.setSelectedSource(e.target.value); + this.mapManager.updateCoverageDisplay(); + }); + } + } + + setupAircraftSelection() { + document.addEventListener('aircraftSelected', (e) => { + const { icao, aircraft } = e.detail; + this.uiManager.switchView('map-view'); + + // DON'T change map view - just open popup like Leaflet expects + if (this.mapManager.map && aircraft.Latitude && aircraft.Longitude) { + const marker = this.aircraftManager.aircraftMarkers.get(icao); + if (marker) { + marker.openPopup(); + } + } + }); + } + + handleWebSocketMessage(message) { + switch (message.type) { + case 'initial_data': + console.log('Received initial data - setting up source markers'); + this.updateData(message.data); + // Setup source markers only on initial data load + this.mapManager.updateSourceMarkers(); + break; + case 'aircraft_update': + this.updateData(message.data); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + updateData(data) { + // Update all managers with new data + this.uiManager.updateData(data); + this.aircraftManager.updateAircraftData(data); + this.mapManager.updateSourcesData(data); + + // Update UI components + this.aircraftManager.updateMarkers(); + this.uiManager.updateAircraftTable(); + this.uiManager.updateStatistics(); + this.uiManager.updateHeaderInfo(); + + // Update coverage controls + this.mapManager.updateCoverageControls(); + + if (this.uiManager.currentView === 'radar3d-view') { + this.update3DRadar(); + } + } + + // View switching + async switchView(viewId) { + const actualViewId = this.uiManager.switchView(viewId); + + // Handle view-specific initialization + const baseName = actualViewId.replace('-view', ''); + switch (baseName) { + case 'coverage': + await this.mapManager.initializeCoverageMap(); + break; + case 'radar3d': + this.update3DRadar(); + break; + } + } + + // Charts + initializeCharts() { + const aircraftChartCanvas = document.getElementById('aircraft-chart'); + if (!aircraftChartCanvas) { + console.warn('Aircraft chart canvas not found'); + return; + } + + try { + this.charts.aircraft = new Chart(aircraftChartCanvas, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Aircraft Count', + data: [], + borderColor: '#00d4ff', + backgroundColor: 'rgba(0, 212, 255, 0.1)', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + x: { display: false }, + y: { + beginAtZero: true, + ticks: { color: '#888' } + } + } + } + }); + } catch (error) { + console.warn('Chart.js not available, skipping charts initialization'); + } + } + + updateCharts() { + if (!this.charts.aircraft) return; + + const now = new Date(); + const timeLabel = now.toLocaleTimeString(); + + // Update aircraft count chart + const chart = this.charts.aircraft; + chart.data.labels.push(timeLabel); + chart.data.datasets[0].data.push(this.aircraftManager.aircraftData.size); + + if (chart.data.labels.length > 20) { + chart.data.labels.shift(); + chart.data.datasets[0].data.shift(); + } + + chart.update('none'); + } + + // 3D Radar (basic implementation) + initialize3DRadar() { + try { + const container = document.getElementById('radar3d-container'); + if (!container) return; + + // Create scene + this.radar3d = { + scene: new THREE.Scene(), + camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000), + renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }), + controls: null, + aircraftMeshes: new Map() + }; + + // Set up renderer + this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight); + this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9); + container.appendChild(this.radar3d.renderer.domElement); + + // Add lighting + const ambientLight = new THREE.AmbientLight(0x404040, 0.6); + this.radar3d.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(10, 10, 5); + this.radar3d.scene.add(directionalLight); + + // Set up camera + this.radar3d.camera.position.set(0, 50, 50); + this.radar3d.camera.lookAt(0, 0, 0); + + // Add controls + this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement); + this.radar3d.controls.enableDamping = true; + this.radar3d.controls.dampingFactor = 0.05; + + // Add ground plane + const groundGeometry = new THREE.PlaneGeometry(200, 200); + const groundMaterial = new THREE.MeshLambertMaterial({ + color: 0x2a4d3a, + transparent: true, + opacity: 0.5 + }); + const ground = new THREE.Mesh(groundGeometry, groundMaterial); + ground.rotation.x = -Math.PI / 2; + this.radar3d.scene.add(ground); + + // Add grid + const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44); + this.radar3d.scene.add(gridHelper); + + // Start render loop + this.render3DRadar(); + + console.log('3D Radar initialized successfully'); + } catch (error) { + console.error('Failed to initialize 3D radar:', error); + } + } + + update3DRadar() { + if (!this.radar3d || !this.radar3d.scene || !this.aircraftManager) return; + + try { + // Update aircraft positions in 3D space + this.aircraftManager.aircraftData.forEach((aircraft, icao) => { + if (aircraft.Latitude && aircraft.Longitude) { + const key = icao.toString(); + + if (!this.radar3d.aircraftMeshes.has(key)) { + // Create new aircraft mesh + const geometry = new THREE.ConeGeometry(0.5, 2, 6); + const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 }); + const mesh = new THREE.Mesh(geometry, material); + this.radar3d.aircraftMeshes.set(key, mesh); + this.radar3d.scene.add(mesh); + } + + const mesh = this.radar3d.aircraftMeshes.get(key); + + // Convert lat/lon to local coordinates (simplified) + const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000; + const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000; + const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display + + mesh.position.set(x, y, z); + + // Orient mesh based on track + if (aircraft.Track !== undefined) { + mesh.rotation.y = -aircraft.Track * Math.PI / 180; + } + } + }); + + // Remove old aircraft + this.radar3d.aircraftMeshes.forEach((mesh, key) => { + if (!this.aircraftManager.aircraftData.has(key)) { + this.radar3d.scene.remove(mesh); + this.radar3d.aircraftMeshes.delete(key); + } + }); + } catch (error) { + console.error('Failed to update 3D radar:', error); + } + } + + render3DRadar() { + if (!this.radar3d) return; + + requestAnimationFrame(() => this.render3DRadar()); + + if (this.radar3d.controls) { + this.radar3d.controls.update(); + } + + this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera); + } + + startPeriodicTasks() { + // Update clocks every second + setInterval(() => this.uiManager.updateClocks(), 1000); + + // Update charts every 10 seconds + setInterval(() => this.updateCharts(), 10000); + + // Periodic cleanup + setInterval(() => { + // Clean up old trail data, etc. + }, 30000); + } +} + +// Initialize application when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + window.skyview = new SkyView(); +}); \ No newline at end of file diff --git a/assets/static/js/app.js b/assets/static/js/app.js index 78d0443..20fef9b 100644 --- a/assets/static/js/app.js +++ b/assets/static/js/app.js @@ -130,6 +130,11 @@ class SkyView { document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails()); document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles()); document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources()); + + // If we already have aircraft data waiting, update markers now that map is ready + if (this.aircraftData.size > 0) { + this.updateMapMarkers(); + } } async initializeCoverageMap() { @@ -251,6 +256,11 @@ class SkyView { // Map Updates updateMapMarkers() { + // Check if map is initialized + if (!this.map) { + return; + } + // Clear stale aircraft markers const currentICAOs = new Set(this.aircraftData.keys()); for (const [icao, marker] of this.aircraftMarkers) { @@ -261,9 +271,10 @@ class SkyView { } } - // Update aircraft markers + // Update aircraft markers - only for aircraft with valid positions + // Note: Aircraft without positions are still shown in the table view for (const [icao, aircraft] of this.aircraftData) { - if (aircraft.Latitude && aircraft.Longitude) { + if (aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0) { this.updateAircraftMarker(icao, aircraft); } } @@ -486,10 +497,10 @@ class SkyView { const distance = this.calculateDistance(aircraft); const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A'; - const sources = aircraft.Sources ? Object.keys(aircraft.Sources).map(id => { + const sources = aircraft.sources ? Object.keys(aircraft.sources).map(id => { const source = this.sourcesData.get(id); - const srcData = aircraft.Sources[id]; - return ` + const srcData = aircraft.sources[id]; + return ` ${source?.name || id} `; }).join('') : 'N/A'; @@ -567,7 +578,7 @@ class SkyView { createSourcePopupContent(source) { const aircraftCount = Array.from(this.aircraftData.values()) - .filter(aircraft => aircraft.Sources && aircraft.Sources[source.id]).length; + .filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length; return `
@@ -599,6 +610,9 @@ class SkyView { // Table Management updateAircraftTable() { + // Note: This table shows ALL aircraft we're tracking, including those without + // position data. Aircraft without positions will show "No position" in the + // location column but still provide useful info like callsign, altitude, etc. const tbody = document.getElementById('aircraft-tbody'); tbody.innerHTML = ''; @@ -618,7 +632,7 @@ class SkyView { if (sourceFilter) { filteredData = filteredData.filter(aircraft => - aircraft.Sources && aircraft.Sources[sourceFilter] + aircraft.sources && aircraft.sources[sourceFilter] ); } @@ -642,8 +656,8 @@ class SkyView { const icao = aircraft.ICAO24 || 'N/A'; const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0; const distance = this.calculateDistance(aircraft); - const sources = aircraft.Sources ? Object.keys(aircraft.Sources).length : 0; - const bestSignal = this.getBestSignalFromSources(aircraft.Sources); + const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0; + const bestSignal = this.getBestSignalFromSources(aircraft.sources); const row = document.createElement('tr'); row.innerHTML = ` @@ -677,8 +691,8 @@ class SkyView { if (!sources) return null; let bestSignal = -999; for (const [id, data] of Object.entries(sources)) { - if (data.SignalLevel > bestSignal) { - bestSignal = data.SignalLevel; + if (data.signal_level > bestSignal) { + bestSignal = data.signal_level; } } return bestSignal === -999 ? null : bestSignal; @@ -725,7 +739,7 @@ class SkyView { case 'squawk': return (a.Squawk || '').localeCompare(b.Squawk || ''); case 'signal': - return (this.getBestSignalFromSources(b.Sources) || -999) - (this.getBestSignalFromSources(a.Sources) || -999); + return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999); case 'age': return (a.Age || 0) - (b.Age || 0); default: @@ -1127,9 +1141,9 @@ class SkyView { // Use closest source as reference point let minDistance = Infinity; - for (const [id, srcData] of Object.entries(aircraft.Sources || {})) { - if (srcData.Distance && srcData.Distance < minDistance) { - minDistance = srcData.Distance; + for (const [id, srcData] of Object.entries(aircraft.sources || {})) { + if (srcData.distance && srcData.distance < minDistance) { + minDistance = srcData.distance; } } diff --git a/assets/static/js/modules/aircraft-manager.js b/assets/static/js/modules/aircraft-manager.js new file mode 100644 index 0000000..f955558 --- /dev/null +++ b/assets/static/js/modules/aircraft-manager.js @@ -0,0 +1,376 @@ +// Aircraft marker and data management module +export class AircraftManager { + constructor(map) { + this.map = map; + this.aircraftData = new Map(); + this.aircraftMarkers = new Map(); + this.aircraftTrails = new Map(); + this.showTrails = false; + + // Debug: Track marker lifecycle + this.markerCreateCount = 0; + this.markerUpdateCount = 0; + this.markerRemoveCount = 0; + + // Map event listeners removed - let Leaflet handle positioning naturally + } + + + updateAircraftData(data) { + if (data.aircraft) { + this.aircraftData.clear(); + for (const [icao, aircraft] of Object.entries(data.aircraft)) { + this.aircraftData.set(icao, aircraft); + } + console.log(`Aircraft data updated: ${this.aircraftData.size} aircraft`); + } + } + + updateMarkers() { + if (!this.map) { + return; + } + + // Clear stale aircraft markers + const currentICAOs = new Set(this.aircraftData.keys()); + for (const [icao, marker] of this.aircraftMarkers) { + if (!currentICAOs.has(icao)) { + this.map.removeLayer(marker); + this.aircraftMarkers.delete(icao); + this.aircraftTrails.delete(icao); + this.markerRemoveCount++; + } + } + + // Update aircraft markers - only for aircraft with valid geographic coordinates + for (const [icao, aircraft] of this.aircraftData) { + const hasCoords = aircraft.Latitude && aircraft.Longitude && 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); + } + } + } + + updateAircraftMarker(icao, aircraft) { + const pos = [aircraft.Latitude, aircraft.Longitude]; + + // Debug: Log coordinate format and values + console.log(`šŸ“ ${icao}: pos=[${pos[0]}, ${pos[1]}], types=[${typeof pos[0]}, ${typeof pos[1]}]`); + + // Check for invalid coordinates - proper geographic bounds + const isValidLat = pos[0] >= -90 && pos[0] <= 90; + const isValidLng = pos[1] >= -180 && pos[1] <= 180; + + if (!isValidLat || !isValidLng || isNaN(pos[0]) || isNaN(pos[1])) { + console.error(`🚨 Invalid coordinates for ${icao}: [${pos[0]}, ${pos[1]}] (lat must be -90 to +90, lng must be -180 to +180)`); + return; // Don't create/update marker with invalid coordinates + } + + if (this.aircraftMarkers.has(icao)) { + // Update existing marker - KISS approach + const marker = this.aircraftMarkers.get(icao); + + // Always update position - let Leaflet handle everything + marker.setLatLng(pos); + + // Update rotation using Leaflet's options if available, otherwise skip rotation + if (aircraft.Track !== undefined) { + if (marker.setRotationAngle) { + // Use Leaflet rotation plugin method if available + marker.setRotationAngle(aircraft.Track); + } else if (marker.options) { + // Update the marker's options for consistency + marker.options.rotationAngle = aircraft.Track; + } + // Don't manually set CSS transforms - let Leaflet handle it + } + + // Handle popup exactly like Leaflet expects + if (marker.isPopupOpen()) { + marker.setPopupContent(this.createPopupContent(aircraft)); + } + + this.markerUpdateCount++; + + } else { + // Create new marker + console.log(`Creating new marker for ${icao}`); + const icon = this.createAircraftIcon(aircraft); + + try { + const marker = L.marker(pos, { + icon: icon, + rotationAngle: aircraft.Track || 0 + }).addTo(this.map); + + marker.bindPopup(this.createPopupContent(aircraft), { + maxWidth: 450, + className: 'aircraft-popup' + }); + + this.aircraftMarkers.set(icao, marker); + this.markerCreateCount++; + console.log(`Created marker for ${icao}, total markers: ${this.aircraftMarkers.size}`); + + // Force immediate visibility + if (marker._icon) { + marker._icon.style.display = 'block'; + marker._icon.style.opacity = '1'; + marker._icon.style.visibility = 'visible'; + console.log(`Forced visibility for new marker ${icao}`); + } + } catch (error) { + console.error(`Failed to create marker for ${icao}:`, error); + } + } + + // Update trails + if (this.showTrails) { + this.updateAircraftTrail(icao, pos); + } + } + + createAircraftIcon(aircraft) { + const type = this.getAircraftType(aircraft); + const color = this.getAircraftColor(type); + const size = aircraft.OnGround ? 12 : 16; + + const svg = ` + + + + + + `; + + return L.divIcon({ + html: svg, + iconSize: [size * 2, size * 2], + iconAnchor: [size, size], + className: 'aircraft-marker' + }); + } + + getAircraftType(aircraft) { + if (aircraft.OnGround) return 'ground'; + if (aircraft.Category) { + const cat = aircraft.Category.toLowerCase(); + if (cat.includes('military')) return 'military'; + if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo'; + if (cat.includes('light') || cat.includes('glider')) return 'ga'; + } + if (aircraft.Callsign) { + const cs = aircraft.Callsign.toLowerCase(); + if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military'; + if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo'; + } + return 'commercial'; + } + + getAircraftColor(type) { + const colors = { + commercial: '#00ff88', + cargo: '#ff8c00', + military: '#ff4444', + ga: '#ffff00', + ground: '#888888' + }; + return colors[type] || colors.commercial; + } + + + updateAircraftTrail(icao, pos) { + if (!this.aircraftTrails.has(icao)) { + this.aircraftTrails.set(icao, []); + } + + const trail = this.aircraftTrails.get(icao); + trail.push(pos); + + // Keep only last 50 positions + if (trail.length > 50) { + trail.shift(); + } + + // Draw polyline + const trailLine = L.polyline(trail, { + color: '#00d4ff', + weight: 2, + opacity: 0.6 + }).addTo(this.map); + + // Store reference for cleanup + if (!this.aircraftTrails.get(icao).polyline) { + this.aircraftTrails.get(icao).polyline = trailLine; + } else { + this.map.removeLayer(this.aircraftTrails.get(icao).polyline); + this.aircraftTrails.get(icao).polyline = trailLine; + } + } + + createPopupContent(aircraft) { + const type = this.getAircraftType(aircraft); + const country = this.getCountryFromICAO(aircraft.ICAO24 || ''); + const flag = this.getCountryFlag(country); + + const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0; + const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0; + const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0; + const distance = this.calculateDistance(aircraft); + const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A'; + + return ` +
+ + + +
+ `; + } + + + calculateDistance(aircraft) { + if (!aircraft.Latitude || !aircraft.Longitude) return null; + + // Use closest source as reference point + let minDistance = Infinity; + for (const [id, srcData] of Object.entries(aircraft.sources || {})) { + if (srcData.distance && srcData.distance < minDistance) { + minDistance = srcData.distance; + } + } + + return minDistance === Infinity ? null : minDistance; + } + + getCountryFromICAO(icao) { + if (!icao || icao.length < 6) return 'Unknown'; + + const prefix = icao[0]; + const countryMap = { + '4': 'Europe', + 'A': 'United States', + 'C': 'Canada', + 'D': 'Germany', + 'F': 'France', + 'G': 'United Kingdom', + 'I': 'Italy', + 'J': 'Japan' + }; + + return countryMap[prefix] || 'Unknown'; + } + + getCountryFlag(country) { + const flags = { + 'United States': 'šŸ‡ŗšŸ‡ø', + 'Canada': 'šŸ‡ØšŸ‡¦', + 'Germany': 'šŸ‡©šŸ‡Ŗ', + 'France': 'šŸ‡«šŸ‡·', + 'United Kingdom': 'šŸ‡¬šŸ‡§', + 'Italy': 'šŸ‡®šŸ‡¹', + 'Japan': 'šŸ‡ÆšŸ‡µ', + 'Europe': 'šŸ‡ŖšŸ‡ŗ' + }; + return flags[country] || 'šŸ³ļø'; + } + + toggleTrails() { + this.showTrails = !this.showTrails; + + if (!this.showTrails) { + // Clear all trails + this.aircraftTrails.forEach((trail, icao) => { + if (trail.polyline) { + this.map.removeLayer(trail.polyline); + } + }); + this.aircraftTrails.clear(); + } + + return this.showTrails; + } + + centerMapOnAircraft() { + if (this.aircraftData.size === 0) return; + + const validAircraft = Array.from(this.aircraftData.values()) + .filter(a => a.Latitude && a.Longitude); + + if (validAircraft.length === 0) return; + + if (validAircraft.length === 1) { + // Center on single aircraft + const aircraft = validAircraft[0]; + this.map.setView([aircraft.Latitude, aircraft.Longitude], 12); + } else { + // Fit bounds to all aircraft + const bounds = L.latLngBounds( + validAircraft.map(a => [a.Latitude, a.Longitude]) + ); + this.map.fitBounds(bounds.pad(0.1)); + } + } + + // Simple debug method + debugState() { + console.log(`Aircraft: ${this.aircraftData.size}, Markers: ${this.aircraftMarkers.size}`); + } +} \ No newline at end of file diff --git a/assets/static/js/modules/map-manager.js b/assets/static/js/modules/map-manager.js new file mode 100644 index 0000000..cabe8c9 --- /dev/null +++ b/assets/static/js/modules/map-manager.js @@ -0,0 +1,315 @@ +// Map and visualization management module +export class MapManager { + constructor() { + this.map = null; + this.coverageMap = null; + this.mapOrigin = null; + + // Source markers and overlays + this.sourceMarkers = new Map(); + this.rangeCircles = new Map(); + this.showSources = true; + this.showRange = false; + this.selectedSource = null; + this.heatmapLayer = null; + + // Data references + this.sourcesData = new Map(); + } + + async initializeMap() { + // Get origin from server + let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback + try { + const response = await fetch('/api/origin'); + if (response.ok) { + origin = await response.json(); + } + } catch (error) { + console.warn('Could not fetch origin, using default:', error); + } + + // Store origin for reset functionality + this.mapOrigin = origin; + + this.map = L.map('map').setView([origin.latitude, origin.longitude], 10); + + // Dark tile layer + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19 + }).addTo(this.map); + + console.log('Main map initialized'); + return this.map; + } + + async initializeCoverageMap() { + if (!this.coverageMap) { + // Get origin from server + let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback + try { + const response = await fetch('/api/origin'); + if (response.ok) { + origin = await response.json(); + } + } catch (error) { + console.warn('Could not fetch origin for coverage map, using default:', error); + } + + this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.coverageMap); + } + + return this.coverageMap; + } + + updateSourcesData(data) { + if (data.sources) { + this.sourcesData.clear(); + data.sources.forEach(source => { + this.sourcesData.set(source.id, source); + }); + } + } + + updateSourceMarkers() { + if (!this.map || !this.showSources) return; + + // Remove markers for sources that no longer exist + const currentSourceIds = new Set(this.sourcesData.keys()); + for (const [id, marker] of this.sourceMarkers) { + if (!currentSourceIds.has(id)) { + this.map.removeLayer(marker); + this.sourceMarkers.delete(id); + } + } + + // Update or create markers for current sources + for (const [id, source] of this.sourcesData) { + if (source.latitude && source.longitude) { + if (this.sourceMarkers.has(id)) { + // Update existing marker + const marker = this.sourceMarkers.get(id); + + // Update marker style if status changed + marker.setStyle({ + radius: source.active ? 10 : 6, + fillColor: source.active ? '#00d4ff' : '#666666', + fillOpacity: 0.8 + }); + + // Update popup content if it's open + if (marker.isPopupOpen()) { + marker.setPopupContent(this.createSourcePopupContent(source)); + } + } else { + // Create new marker + const marker = L.circleMarker([source.latitude, source.longitude], { + radius: source.active ? 10 : 6, + fillColor: source.active ? '#00d4ff' : '#666666', + color: '#ffffff', + weight: 2, + fillOpacity: 0.8, + className: 'source-marker' + }).addTo(this.map); + + marker.bindPopup(this.createSourcePopupContent(source), { + maxWidth: 300 + }); + + this.sourceMarkers.set(id, marker); + } + } + } + + this.updateSourcesLegend(); + } + + updateRangeCircles() { + if (!this.map || !this.showRange) return; + + // Clear existing circles + this.rangeCircles.forEach(circle => this.map.removeLayer(circle)); + this.rangeCircles.clear(); + + // Add range circles for active sources + for (const [id, source] of this.sourcesData) { + if (source.active && source.latitude && source.longitude) { + // Add multiple range circles (50km, 100km, 200km) + const ranges = [50000, 100000, 200000]; + ranges.forEach((range, index) => { + const circle = L.circle([source.latitude, source.longitude], { + radius: range, + fillColor: 'transparent', + color: '#00d4ff', + weight: 1, + opacity: 0.3 - (index * 0.1), + dashArray: '5,5' + }).addTo(this.map); + + this.rangeCircles.set(`${id}_${range}`, circle); + }); + } + } + } + + createSourcePopupContent(source, aircraftData) { + const aircraftCount = aircraftData ? Array.from(aircraftData.values()) + .filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0; + + return ` +
+

${source.name}

+

ID: ${source.id}

+

Location: ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°

+

Status: ${source.active ? 'Active' : 'Inactive'}

+

Aircraft: ${aircraftCount}

+

Messages: ${source.messages || 0}

+

Last Seen: ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}

+
+ `; + } + + updateSourcesLegend() { + const legend = document.getElementById('sources-legend'); + if (!legend) return; + + legend.innerHTML = ''; + + for (const [id, source] of this.sourcesData) { + const item = document.createElement('div'); + item.className = 'legend-item'; + item.innerHTML = ` + + ${source.name} + `; + legend.appendChild(item); + } + } + + resetMap() { + if (this.mapOrigin && this.map) { + this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10); + } + } + + toggleRangeCircles() { + this.showRange = !this.showRange; + + if (this.showRange) { + this.updateRangeCircles(); + } else { + this.rangeCircles.forEach(circle => this.map.removeLayer(circle)); + this.rangeCircles.clear(); + } + + return this.showRange; + } + + toggleSources() { + this.showSources = !this.showSources; + + if (this.showSources) { + this.updateSourceMarkers(); + } else { + this.sourceMarkers.forEach(marker => this.map.removeLayer(marker)); + this.sourceMarkers.clear(); + } + + return this.showSources; + } + + // Coverage map methods + updateCoverageControls() { + const select = document.getElementById('coverage-source'); + if (!select) return; + + select.innerHTML = ''; + + for (const [id, source] of this.sourcesData) { + const option = document.createElement('option'); + option.value = id; + option.textContent = source.name; + select.appendChild(option); + } + } + + async updateCoverageDisplay() { + if (!this.selectedSource || !this.coverageMap) return; + + try { + const response = await fetch(`/api/coverage/${this.selectedSource}`); + const data = await response.json(); + + // Clear existing coverage markers + this.coverageMap.eachLayer(layer => { + if (layer instanceof L.CircleMarker) { + this.coverageMap.removeLayer(layer); + } + }); + + // Add coverage points + data.points.forEach(point => { + const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength + L.circleMarker([point.lat, point.lon], { + radius: 3, + fillColor: this.getSignalColor(point.signal), + color: 'white', + weight: 1, + fillOpacity: intensity + }).addTo(this.coverageMap); + }); + + } catch (error) { + console.error('Failed to load coverage data:', error); + } + } + + async toggleHeatmap() { + if (!this.selectedSource) { + alert('Please select a source first'); + return false; + } + + if (this.heatmapLayer) { + this.coverageMap.removeLayer(this.heatmapLayer); + this.heatmapLayer = null; + return false; + } else { + try { + const response = await fetch(`/api/heatmap/${this.selectedSource}`); + const data = await response.json(); + + // Create heatmap layer (simplified) + this.createHeatmapOverlay(data); + return true; + + } catch (error) { + console.error('Failed to load heatmap data:', error); + return false; + } + } + } + + getSignalColor(signal) { + if (signal > -10) return '#00ff88'; + if (signal > -20) return '#ffff00'; + if (signal > -30) return '#ff8c00'; + return '#ff4444'; + } + + createHeatmapOverlay(data) { + // Simplified heatmap implementation + // In production, would use proper heatmap library like Leaflet.heat + console.log('Creating heatmap overlay with data:', data); + } + + setSelectedSource(sourceId) { + this.selectedSource = sourceId; + } +} \ No newline at end of file diff --git a/assets/static/js/modules/ui-manager.js b/assets/static/js/modules/ui-manager.js new file mode 100644 index 0000000..3af6789 --- /dev/null +++ b/assets/static/js/modules/ui-manager.js @@ -0,0 +1,321 @@ +// UI and table management module +export class UIManager { + constructor() { + this.aircraftData = new Map(); + this.sourcesData = new Map(); + this.stats = {}; + this.currentView = 'map-view'; + this.lastUpdateTime = new Date(); + } + + initializeViews() { + const viewButtons = document.querySelectorAll('.view-btn'); + const views = document.querySelectorAll('.view'); + + viewButtons.forEach(btn => { + btn.addEventListener('click', () => { + const viewId = btn.id.replace('-btn', ''); + this.switchView(viewId); + }); + }); + } + + switchView(viewId) { + // Update buttons + document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active')); + const activeBtn = document.getElementById(`${viewId}-btn`); + if (activeBtn) { + activeBtn.classList.add('active'); + } + + // Update views (viewId already includes the full view ID like "map-view") + document.querySelectorAll('.view').forEach(view => view.classList.remove('active')); + const activeView = document.getElementById(viewId); + if (activeView) { + activeView.classList.add('active'); + } else { + console.warn(`View element not found: ${viewId}`); + return; + } + + this.currentView = viewId; + return viewId; + } + + updateData(data) { + // Update aircraft data + if (data.aircraft) { + this.aircraftData.clear(); + for (const [icao, aircraft] of Object.entries(data.aircraft)) { + this.aircraftData.set(icao, aircraft); + } + } + + // Update sources data + if (data.sources) { + this.sourcesData.clear(); + data.sources.forEach(source => { + this.sourcesData.set(source.id, source); + }); + } + + // Update statistics + if (data.stats) { + this.stats = data.stats; + } + + this.lastUpdateTime = new Date(); + } + + updateAircraftTable() { + // Note: This table shows ALL aircraft we're tracking, including those without + // position data. Aircraft without positions will show "No position" in the + // location column but still provide useful info like callsign, altitude, etc. + const tbody = document.getElementById('aircraft-tbody'); + if (!tbody) return; + + tbody.innerHTML = ''; + + let filteredData = Array.from(this.aircraftData.values()); + + // Apply filters + const searchTerm = document.getElementById('search-input')?.value.toLowerCase() || ''; + const sourceFilter = document.getElementById('source-filter')?.value || ''; + + if (searchTerm) { + filteredData = filteredData.filter(aircraft => + (aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) || + (aircraft.ICAO24 && aircraft.ICAO24.toLowerCase().includes(searchTerm)) || + (aircraft.Squawk && aircraft.Squawk.includes(searchTerm)) + ); + } + + if (sourceFilter) { + filteredData = filteredData.filter(aircraft => + aircraft.sources && aircraft.sources[sourceFilter] + ); + } + + // Sort data + const sortBy = document.getElementById('sort-select')?.value || 'distance'; + this.sortAircraft(filteredData, sortBy); + + // Populate table + filteredData.forEach(aircraft => { + const row = this.createTableRow(aircraft); + tbody.appendChild(row); + }); + + // Update source filter options + this.updateSourceFilter(); + } + + createTableRow(aircraft) { + const type = this.getAircraftType(aircraft); + const icao = aircraft.ICAO24 || 'N/A'; + const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0; + const distance = this.calculateDistance(aircraft); + const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0; + const bestSignal = this.getBestSignalFromSources(aircraft.sources); + + const row = document.createElement('tr'); + row.innerHTML = ` + ${icao} + ${aircraft.Callsign || '-'} + ${aircraft.Squawk || '-'} + ${altitude ? `${altitude} ft` : '-'} + ${aircraft.GroundSpeed || '-'} kt + ${distance ? distance.toFixed(1) : '-'} km + ${aircraft.Track || '-'}° + ${sources} + ${bestSignal ? bestSignal.toFixed(1) : '-'} + ${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s + `; + + row.addEventListener('click', () => { + if (aircraft.Latitude && aircraft.Longitude) { + // Trigger event to switch to map and focus on aircraft + const event = new CustomEvent('aircraftSelected', { + detail: { icao, aircraft } + }); + document.dispatchEvent(event); + } + }); + + return row; + } + + getAircraftType(aircraft) { + if (aircraft.OnGround) return 'ground'; + if (aircraft.Category) { + const cat = aircraft.Category.toLowerCase(); + if (cat.includes('military')) return 'military'; + if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo'; + if (cat.includes('light') || cat.includes('glider')) return 'ga'; + } + if (aircraft.Callsign) { + const cs = aircraft.Callsign.toLowerCase(); + if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military'; + if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo'; + } + return 'commercial'; + } + + getBestSignalFromSources(sources) { + if (!sources) return null; + let bestSignal = -999; + for (const [id, data] of Object.entries(sources)) { + if (data.signal_level > bestSignal) { + bestSignal = data.signal_level; + } + } + return bestSignal === -999 ? null : bestSignal; + } + + getSignalClass(signal) { + if (!signal) return ''; + if (signal > -10) return 'signal-strong'; + if (signal > -20) return 'signal-good'; + if (signal > -30) return 'signal-weak'; + return 'signal-poor'; + } + + updateSourceFilter() { + const select = document.getElementById('source-filter'); + if (!select) return; + + const currentValue = select.value; + + // Clear options except "All Sources" + select.innerHTML = ''; + + // Add source options + for (const [id, source] of this.sourcesData) { + const option = document.createElement('option'); + option.value = id; + option.textContent = source.name; + if (id === currentValue) option.selected = true; + select.appendChild(option); + } + } + + sortAircraft(aircraft, sortBy) { + aircraft.sort((a, b) => { + switch (sortBy) { + case 'distance': + return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity); + case 'altitude': + return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0); + case 'speed': + return (b.GroundSpeed || 0) - (a.GroundSpeed || 0); + case 'flight': + return (a.Callsign || a.ICAO24 || '').localeCompare(b.Callsign || b.ICAO24 || ''); + case 'icao': + return (a.ICAO24 || '').localeCompare(b.ICAO24 || ''); + case 'squawk': + return (a.Squawk || '').localeCompare(b.Squawk || ''); + case 'signal': + return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999); + case 'age': + return (a.Age || 0) - (b.Age || 0); + default: + return 0; + } + }); + } + + calculateDistance(aircraft) { + if (!aircraft.Latitude || !aircraft.Longitude) return null; + + // Use closest source as reference point + let minDistance = Infinity; + for (const [id, srcData] of Object.entries(aircraft.sources || {})) { + if (srcData.distance && srcData.distance < minDistance) { + minDistance = srcData.distance; + } + } + + return minDistance === Infinity ? null : minDistance; + } + + updateStatistics() { + const totalAircraftEl = document.getElementById('total-aircraft'); + const activeSourcesEl = document.getElementById('active-sources'); + const maxRangeEl = document.getElementById('max-range'); + const messagesSecEl = document.getElementById('messages-sec'); + + if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size; + if (activeSourcesEl) { + activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length; + } + + // Calculate max range + let maxDistance = 0; + for (const aircraft of this.aircraftData.values()) { + const distance = this.calculateDistance(aircraft); + if (distance && distance > maxDistance) { + maxDistance = distance; + } + } + if (maxRangeEl) maxRangeEl.textContent = `${maxDistance.toFixed(1)} km`; + + // Update message rate + const totalMessages = this.stats.total_messages || 0; + if (messagesSecEl) messagesSecEl.textContent = Math.round(totalMessages / 60); + } + + updateHeaderInfo() { + const aircraftCountEl = document.getElementById('aircraft-count'); + const sourcesCountEl = document.getElementById('sources-count'); + + if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`; + if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`; + + this.updateClocks(); + } + + updateConnectionStatus(status) { + const statusEl = document.getElementById('connection-status'); + if (statusEl) { + statusEl.className = `connection-status ${status}`; + statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected'; + } + } + + initializeEventListeners() { + const searchInput = document.getElementById('search-input'); + const sortSelect = document.getElementById('sort-select'); + const sourceFilter = document.getElementById('source-filter'); + + if (searchInput) searchInput.addEventListener('input', () => this.updateAircraftTable()); + if (sortSelect) sortSelect.addEventListener('change', () => this.updateAircraftTable()); + if (sourceFilter) sourceFilter.addEventListener('change', () => this.updateAircraftTable()); + } + + updateClocks() { + const now = new Date(); + const utcNow = new Date(now.getTime() + (now.getTimezoneOffset() * 60000)); + + this.updateClock('utc', utcNow); + this.updateClock('update', this.lastUpdateTime); + } + + updateClock(prefix, time) { + const hours = time.getUTCHours(); + const minutes = time.getUTCMinutes(); + + const hourAngle = (hours % 12) * 30 + minutes * 0.5; + const minuteAngle = minutes * 6; + + const hourHand = document.getElementById(`${prefix}-hour`); + const minuteHand = document.getElementById(`${prefix}-minute`); + + if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`; + if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`; + } + + showError(message) { + console.error(message); + // Could implement toast notifications here + } +} \ No newline at end of file diff --git a/assets/static/js/modules/websocket.js b/assets/static/js/modules/websocket.js new file mode 100644 index 0000000..40e08f7 --- /dev/null +++ b/assets/static/js/modules/websocket.js @@ -0,0 +1,70 @@ +// WebSocket communication module +export class WebSocketManager { + constructor(onMessage, onStatusChange) { + this.websocket = null; + this.onMessage = onMessage; + this.onStatusChange = onStatusChange; + } + + async connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + try { + this.websocket = new WebSocket(wsUrl); + + this.websocket.onopen = () => { + console.log('WebSocket connected'); + this.onStatusChange('connected'); + }; + + this.websocket.onclose = () => { + console.log('WebSocket disconnected'); + this.onStatusChange('disconnected'); + // Reconnect after 5 seconds + setTimeout(() => this.connect(), 5000); + }; + + this.websocket.onerror = (error) => { + console.error('WebSocket error:', error); + this.onStatusChange('disconnected'); + }; + + this.websocket.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + // Debug: Log WebSocket messages to see what we're receiving + if (message.data && message.data.aircraft) { + const aircraftCount = Object.keys(message.data.aircraft).length; + console.log(`šŸ“” WebSocket: ${message.type} with ${aircraftCount} aircraft`); + + // Log first few aircraft with coordinates + let count = 0; + for (const [icao, aircraft] of Object.entries(message.data.aircraft)) { + if (count < 3 && aircraft.Latitude && aircraft.Longitude) { + console.log(`šŸ“” ${icao}: lat=${aircraft.Latitude}, lon=${aircraft.Longitude}`); + } + count++; + } + } + + this.onMessage(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + } catch (error) { + console.error('WebSocket connection failed:', error); + this.onStatusChange('disconnected'); + } + } + + disconnect() { + if (this.websocket) { + this.websocket.close(); + this.websocket = null; + } + } +} \ No newline at end of file diff --git a/cmd/beast-dump/main.go b/cmd/beast-dump/main.go new file mode 100644 index 0000000..202d366 --- /dev/null +++ b/cmd/beast-dump/main.go @@ -0,0 +1,440 @@ +// Package main provides a utility for parsing and displaying Beast format ADS-B data. +// +// beast-dump can read from TCP sockets (dump1090 streams) or files containing +// Beast binary data, decode Mode S/ADS-B messages, and display the results +// in human-readable format on the console. +// +// Usage: +// beast-dump -tcp host:port # Read from TCP socket +// beast-dump -file path/to/file # Read from file +// beast-dump -verbose # Show detailed message parsing +// +// Examples: +// beast-dump -tcp svovel:30005 # Connect to dump1090 Beast stream +// beast-dump -file beast.test # Parse Beast data from file +// beast-dump -tcp localhost:30005 -verbose # Verbose TCP parsing +package main + +import ( + "flag" + "fmt" + "io" + "log" + "net" + "os" + "time" + + "skyview/internal/beast" + "skyview/internal/modes" +) + +// Config holds command-line configuration +type Config struct { + TCPAddress string // TCP address for Beast stream (e.g., "localhost:30005") + FilePath string // File path for Beast data + Verbose bool // Enable verbose output + Count int // Maximum messages to process (0 = unlimited) +} + +// BeastDumper handles Beast data parsing and console output +type BeastDumper struct { + config *Config + parser *beast.Parser + decoder *modes.Decoder + stats struct { + totalMessages int64 + validMessages int64 + aircraftSeen map[uint32]bool + startTime time.Time + lastMessageTime time.Time + } +} + +func main() { + config := parseFlags() + + if config.TCPAddress == "" && config.FilePath == "" { + fmt.Fprintf(os.Stderr, "Error: Must specify either -tcp or -file\n") + flag.Usage() + os.Exit(1) + } + + if config.TCPAddress != "" && config.FilePath != "" { + fmt.Fprintf(os.Stderr, "Error: Cannot specify both -tcp and -file\n") + flag.Usage() + os.Exit(1) + } + + dumper := NewBeastDumper(config) + + if err := dumper.Run(); err != nil { + log.Fatalf("Error: %v", err) + } +} + +// parseFlags parses command-line flags and returns configuration +func parseFlags() *Config { + config := &Config{} + + flag.StringVar(&config.TCPAddress, "tcp", "", "TCP address for Beast stream (e.g., localhost:30005)") + flag.StringVar(&config.FilePath, "file", "", "File path for Beast data") + flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose output") + flag.IntVar(&config.Count, "count", 0, "Maximum messages to process (0 = unlimited)") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nBeast format ADS-B data parser and console dumper\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " %s -tcp svovel:30005\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s -file beast.test\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s -tcp localhost:30005 -verbose -count 100\n", os.Args[0]) + } + + flag.Parse() + return config +} + +// NewBeastDumper creates a new Beast data dumper +func NewBeastDumper(config *Config) *BeastDumper { + return &BeastDumper{ + config: config, + decoder: modes.NewDecoder(), + stats: struct { + totalMessages int64 + validMessages int64 + aircraftSeen map[uint32]bool + startTime time.Time + lastMessageTime time.Time + }{ + aircraftSeen: make(map[uint32]bool), + startTime: time.Now(), + }, + } +} + +// Run starts the Beast data processing +func (d *BeastDumper) Run() error { + fmt.Printf("Beast Data Dumper\n") + fmt.Printf("=================\n\n") + + var reader io.Reader + var closer io.Closer + + if d.config.TCPAddress != "" { + conn, err := d.connectTCP() + if err != nil { + return fmt.Errorf("TCP connection failed: %w", err) + } + reader = conn + closer = conn + fmt.Printf("Connected to: %s\n", d.config.TCPAddress) + } else { + file, err := d.openFile() + if err != nil { + return fmt.Errorf("file open failed: %w", err) + } + reader = file + closer = file + fmt.Printf("Reading file: %s\n", d.config.FilePath) + } + + defer closer.Close() + + // Create Beast parser + d.parser = beast.NewParser(reader, "beast-dump") + + fmt.Printf("Verbose mode: %t\n", d.config.Verbose) + if d.config.Count > 0 { + fmt.Printf("Message limit: %d\n", d.config.Count) + } + fmt.Printf("\nStarting Beast data parsing...\n") + fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6s %s\n", + "Time", "ICAO", "Type", "Signal", "Data", "Len", "Decoded") + fmt.Printf("%s\n", + "------------------------------------------------------------------------") + + return d.parseMessages() +} + +// connectTCP establishes TCP connection to Beast stream +func (d *BeastDumper) connectTCP() (net.Conn, error) { + fmt.Printf("Connecting to %s...\n", d.config.TCPAddress) + + conn, err := net.DialTimeout("tcp", d.config.TCPAddress, 10*time.Second) + if err != nil { + return nil, err + } + + return conn, nil +} + +// openFile opens Beast data file +func (d *BeastDumper) openFile() (*os.File, error) { + file, err := os.Open(d.config.FilePath) + if err != nil { + return nil, err + } + + // Check file size + stat, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + + fmt.Printf("File size: %d bytes\n", stat.Size()) + return file, nil +} + +// parseMessages processes Beast messages and outputs decoded data +func (d *BeastDumper) parseMessages() error { + for { + // Check message count limit + if d.config.Count > 0 && d.stats.totalMessages >= int64(d.config.Count) { + fmt.Printf("\nReached message limit of %d\n", d.config.Count) + break + } + + // Parse Beast message + msg, err := d.parser.ReadMessage() + if err != nil { + if err == io.EOF { + fmt.Printf("\nEnd of data reached\n") + break + } + if d.config.Verbose { + fmt.Printf("Parse error: %v\n", err) + } + continue + } + + d.stats.totalMessages++ + d.stats.lastMessageTime = time.Now() + + // Display Beast message info + d.displayMessage(msg) + + // Decode Mode S data if available + if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong { + d.decodeAndDisplay(msg) + } + + d.stats.validMessages++ + } + + d.displayStatistics() + return nil +} + +// displayMessage shows basic Beast message information +func (d *BeastDumper) displayMessage(msg *beast.Message) { + timestamp := msg.ReceivedAt.Format("15:04:05") + + // Extract ICAO if available + icao := "------" + if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong { + if icaoAddr, err := msg.GetICAO24(); err == nil { + icao = fmt.Sprintf("%06X", icaoAddr) + d.stats.aircraftSeen[icaoAddr] = true + } + } + + // Beast message type + typeStr := d.formatMessageType(msg.Type) + + // Signal strength + signal := msg.GetSignalStrength() + signalStr := fmt.Sprintf("%6.1f", signal) + + // Data preview + dataStr := d.formatDataPreview(msg.Data) + + fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6d ", + timestamp, icao, typeStr, signalStr, dataStr, len(msg.Data)) +} + +// decodeAndDisplay attempts to decode Mode S message and display results +func (d *BeastDumper) decodeAndDisplay(msg *beast.Message) { + aircraft, err := d.decoder.Decode(msg.Data) + if err != nil { + if d.config.Verbose { + fmt.Printf("Decode error: %v\n", err) + } else { + fmt.Printf("(decode failed)\n") + } + return + } + + // Display decoded information + info := d.formatAircraftInfo(aircraft) + fmt.Printf("%s\n", info) + + // Verbose details + if d.config.Verbose { + d.displayVerboseInfo(aircraft, msg) + } +} + +// formatMessageType converts Beast message type to string +func (d *BeastDumper) formatMessageType(msgType uint8) string { + switch msgType { + case beast.BeastModeAC: + return "Mode A/C" + case beast.BeastModeS: + return "Mode S" + case beast.BeastModeSLong: + return "Mode S Long" + case beast.BeastStatusMsg: + return "Status" + default: + return fmt.Sprintf("Type %02X", msgType) + } +} + +// formatDataPreview creates a hex preview of message data +func (d *BeastDumper) formatDataPreview(data []byte) string { + if len(data) == 0 { + return "" + } + + preview := "" + for i, b := range data { + if i >= 4 { // Show first 4 bytes + break + } + preview += fmt.Sprintf("%02X", b) + } + + if len(data) > 4 { + preview += "..." + } + + return preview +} + +// formatAircraftInfo creates a summary of decoded aircraft information +func (d *BeastDumper) formatAircraftInfo(aircraft *modes.Aircraft) string { + parts := []string{} + + // Callsign + if aircraft.Callsign != "" { + parts = append(parts, fmt.Sprintf("CS:%s", aircraft.Callsign)) + } + + // Position + if aircraft.Latitude != 0 || aircraft.Longitude != 0 { + parts = append(parts, fmt.Sprintf("POS:%.4f,%.4f", aircraft.Latitude, aircraft.Longitude)) + } + + // Altitude + if aircraft.Altitude != 0 { + parts = append(parts, fmt.Sprintf("ALT:%dft", aircraft.Altitude)) + } + + // Speed and track + if aircraft.GroundSpeed != 0 { + parts = append(parts, fmt.Sprintf("SPD:%dkt", aircraft.GroundSpeed)) + } + if aircraft.Track != 0 { + parts = append(parts, fmt.Sprintf("HDG:%d°", aircraft.Track)) + } + + // Vertical rate + if aircraft.VerticalRate != 0 { + parts = append(parts, fmt.Sprintf("VS:%d", aircraft.VerticalRate)) + } + + // Squawk + if aircraft.Squawk != "" { + parts = append(parts, fmt.Sprintf("SQ:%s", aircraft.Squawk)) + } + + // Emergency + if aircraft.Emergency != "" && aircraft.Emergency != "None" { + parts = append(parts, fmt.Sprintf("EMG:%s", aircraft.Emergency)) + } + + if len(parts) == 0 { + return "(no data decoded)" + } + + info := "" + for i, part := range parts { + if i > 0 { + info += " " + } + info += part + } + + return info +} + +// displayVerboseInfo shows detailed aircraft information +func (d *BeastDumper) displayVerboseInfo(aircraft *modes.Aircraft, msg *beast.Message) { + fmt.Printf(" Message Details:\n") + fmt.Printf(" Raw Data: %s\n", d.formatHexData(msg.Data)) + fmt.Printf(" Timestamp: %s\n", msg.ReceivedAt.Format("15:04:05.000")) + fmt.Printf(" Signal: %.2f dBFS\n", msg.GetSignalStrength()) + + fmt.Printf(" Aircraft Data:\n") + if aircraft.Callsign != "" { + fmt.Printf(" Callsign: %s\n", aircraft.Callsign) + } + if aircraft.Latitude != 0 || aircraft.Longitude != 0 { + fmt.Printf(" Position: %.6f, %.6f\n", aircraft.Latitude, aircraft.Longitude) + } + if aircraft.Altitude != 0 { + fmt.Printf(" Altitude: %d ft\n", aircraft.Altitude) + } + if aircraft.GroundSpeed != 0 || aircraft.Track != 0 { + fmt.Printf(" Speed/Track: %d kt @ %d°\n", aircraft.GroundSpeed, aircraft.Track) + } + if aircraft.VerticalRate != 0 { + fmt.Printf(" Vertical Rate: %d ft/min\n", aircraft.VerticalRate) + } + if aircraft.Squawk != "" { + fmt.Printf(" Squawk: %s\n", aircraft.Squawk) + } + if aircraft.Category != "" { + fmt.Printf(" Category: %s\n", aircraft.Category) + } + fmt.Printf("\n") +} + +// formatHexData creates a formatted hex dump of data +func (d *BeastDumper) formatHexData(data []byte) string { + result := "" + for i, b := range data { + if i > 0 { + result += " " + } + result += fmt.Sprintf("%02X", b) + } + return result +} + +// displayStatistics shows final parsing statistics +func (d *BeastDumper) displayStatistics() { + duration := time.Since(d.stats.startTime) + + fmt.Printf("\nStatistics:\n") + fmt.Printf("===========\n") + fmt.Printf("Total messages: %d\n", d.stats.totalMessages) + fmt.Printf("Valid messages: %d\n", d.stats.validMessages) + fmt.Printf("Unique aircraft: %d\n", len(d.stats.aircraftSeen)) + fmt.Printf("Duration: %v\n", duration.Round(time.Second)) + + if d.stats.totalMessages > 0 && duration > 0 { + rate := float64(d.stats.totalMessages) / duration.Seconds() + fmt.Printf("Message rate: %.1f msg/sec\n", rate) + } + + if len(d.stats.aircraftSeen) > 0 { + fmt.Printf("\nAircraft seen:\n") + for icao := range d.stats.aircraftSeen { + fmt.Printf(" %06X\n", icao) + } + } +} \ No newline at end of file diff --git a/internal/beast/parser.go b/internal/beast/parser.go index 178566e..436a728 100644 --- a/internal/beast/parser.go +++ b/internal/beast/parser.go @@ -130,8 +130,13 @@ func (p *Parser) ReadMessage() (*Message, error) { case BeastStatusMsg: // Status messages have variable length, skip for now return p.ReadMessage() + case BeastEscape: + // Handle double escape sequence (0x1A 0x1A) - skip and continue + return p.ReadMessage() default: - return nil, fmt.Errorf("unknown message type: 0x%02x", msgType) + // Skip unknown message types and continue parsing instead of failing + // This makes the parser more resilient to malformed or extended Beast formats + return p.ReadMessage() } // Read timestamp (6 bytes, 48-bit) diff --git a/internal/merger/merger.go b/internal/merger/merger.go index 4c6f7a1..84eed39 100644 --- a/internal/merger/merger.go +++ b/internal/merger/merger.go @@ -20,6 +20,8 @@ package merger import ( + "encoding/json" + "fmt" "math" "sync" "time" @@ -72,6 +74,94 @@ type AircraftState struct { UpdateRate float64 `json:"update_rate"` // Recent updates per second } +// MarshalJSON provides custom JSON marshaling for AircraftState to format ICAO24 as hex. +func (a *AircraftState) MarshalJSON() ([]byte, error) { + // Create a struct that mirrors AircraftState but with ICAO24 as string + return json.Marshal(&struct { + // From embedded modes.Aircraft + ICAO24 string `json:"ICAO24"` + Callsign string `json:"Callsign"` + Latitude float64 `json:"Latitude"` + Longitude float64 `json:"Longitude"` + Altitude int `json:"Altitude"` + BaroAltitude int `json:"BaroAltitude"` + GeomAltitude int `json:"GeomAltitude"` + VerticalRate int `json:"VerticalRate"` + GroundSpeed int `json:"GroundSpeed"` + Track int `json:"Track"` + Heading int `json:"Heading"` + Category string `json:"Category"` + Squawk string `json:"Squawk"` + Emergency string `json:"Emergency"` + OnGround bool `json:"OnGround"` + Alert bool `json:"Alert"` + SPI bool `json:"SPI"` + NACp uint8 `json:"NACp"` + NACv uint8 `json:"NACv"` + SIL uint8 `json:"SIL"` + SelectedAltitude int `json:"SelectedAltitude"` + SelectedHeading float64 `json:"SelectedHeading"` + BaroSetting float64 `json:"BaroSetting"` + + // From AircraftState + Sources map[string]*SourceData `json:"sources"` + LastUpdate time.Time `json:"last_update"` + FirstSeen time.Time `json:"first_seen"` + TotalMessages int64 `json:"total_messages"` + PositionHistory []PositionPoint `json:"position_history"` + SignalHistory []SignalPoint `json:"signal_history"` + AltitudeHistory []AltitudePoint `json:"altitude_history"` + SpeedHistory []SpeedPoint `json:"speed_history"` + Distance float64 `json:"distance"` + Bearing float64 `json:"bearing"` + Age float64 `json:"age"` + MLATSources []string `json:"mlat_sources"` + PositionSource string `json:"position_source"` + UpdateRate float64 `json:"update_rate"` + }{ + // Copy all fields from Aircraft + ICAO24: fmt.Sprintf("%06X", a.Aircraft.ICAO24), + Callsign: a.Aircraft.Callsign, + Latitude: a.Aircraft.Latitude, + Longitude: a.Aircraft.Longitude, + Altitude: a.Aircraft.Altitude, + BaroAltitude: a.Aircraft.BaroAltitude, + GeomAltitude: a.Aircraft.GeomAltitude, + VerticalRate: a.Aircraft.VerticalRate, + GroundSpeed: a.Aircraft.GroundSpeed, + Track: a.Aircraft.Track, + Heading: a.Aircraft.Heading, + Category: a.Aircraft.Category, + Squawk: a.Aircraft.Squawk, + Emergency: a.Aircraft.Emergency, + OnGround: a.Aircraft.OnGround, + Alert: a.Aircraft.Alert, + SPI: a.Aircraft.SPI, + NACp: a.Aircraft.NACp, + NACv: a.Aircraft.NACv, + SIL: a.Aircraft.SIL, + SelectedAltitude: a.Aircraft.SelectedAltitude, + SelectedHeading: a.Aircraft.SelectedHeading, + BaroSetting: a.Aircraft.BaroSetting, + + // Copy all fields from AircraftState + Sources: a.Sources, + LastUpdate: a.LastUpdate, + FirstSeen: a.FirstSeen, + TotalMessages: a.TotalMessages, + PositionHistory: a.PositionHistory, + SignalHistory: a.SignalHistory, + AltitudeHistory: a.AltitudeHistory, + SpeedHistory: a.SpeedHistory, + Distance: a.Distance, + Bearing: a.Bearing, + Age: a.Age, + MLATSources: a.MLATSources, + PositionSource: a.PositionSource, + UpdateRate: a.UpdateRate, + }) +} + // SourceData represents data quality and statistics for a specific source-aircraft pair. // This information is used for data fusion decisions and signal quality analysis. type SourceData struct { @@ -133,7 +223,7 @@ type Merger struct { sources map[string]*Source // Source ID -> source information mu sync.RWMutex // Protects all maps and slices historyLimit int // Maximum history points to retain - staleTimeout time.Duration // Time before aircraft considered stale + staleTimeout time.Duration // Time before aircraft considered stale (15 seconds) updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data } @@ -147,7 +237,7 @@ type updateMetric struct { // // Default settings: // - History limit: 500 points per aircraft -// - Stale timeout: 60 seconds +// - Stale timeout: 15 seconds // - Empty aircraft and source maps // - Update metrics tracking enabled // @@ -157,7 +247,7 @@ func NewMerger() *Merger { aircraft: make(map[uint32]*AircraftState), sources: make(map[string]*Source), historyLimit: 500, - staleTimeout: 60 * time.Second, + staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking updateMetrics: make(map[uint32]*updateMetric), } } @@ -219,6 +309,8 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa updates: make([]time.Time, 0), } } + // Note: For existing aircraft, we don't overwrite state.Aircraft here + // The mergeAircraftData function will handle selective field updates // Update or create source data srcData, srcExists := state.Sources[sourceID] @@ -294,12 +386,16 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so updatePosition := false if state.Latitude == 0 { + // First position update updatePosition = true } else if srcData, ok := state.Sources[sourceID]; ok { // Use position from source with strongest signal currentBest := m.getBestSignalSource(state) if currentBest == "" || srcData.SignalLevel > state.Sources[currentBest].SignalLevel { updatePosition = true + } else if currentBest == sourceID { + // Same source as current best - allow updates for moving aircraft + updatePosition = true } } @@ -615,7 +711,7 @@ func (m *Merger) GetStatistics() map[string]interface{} { // CleanupStale removes aircraft that haven't been updated recently. // // Aircraft are considered stale if they haven't received updates for longer -// than staleTimeout (default 60 seconds). This cleanup prevents memory +// than staleTimeout (default 15 seconds). This cleanup prevents memory // growth from aircraft that have left the coverage area or stopped transmitting. // // The cleanup also removes associated update metrics to free memory. diff --git a/internal/modes/decoder.go b/internal/modes/decoder.go index 6ca1d5f..960504e 100644 --- a/internal/modes/decoder.go +++ b/internal/modes/decoder.go @@ -30,8 +30,45 @@ package modes import ( "fmt" "math" + "sync" ) +// crcTable for Mode S CRC-24 validation +var crcTable [256]uint32 + +func init() { + // Initialize CRC table for Mode S CRC-24 (polynomial 0x1FFF409) + for i := 0; i < 256; i++ { + crc := uint32(i) << 16 + for j := 0; j < 8; j++ { + if crc&0x800000 != 0 { + crc = (crc << 1) ^ 0x1FFF409 + } else { + crc = crc << 1 + } + } + crcTable[i] = crc & 0xFFFFFF + } +} + +// validateModeSCRC validates the 24-bit CRC of a Mode S message +func validateModeSCRC(data []byte) bool { + if len(data) < 4 { + return false + } + + // Calculate CRC for all bytes except the last 3 (which contain the CRC) + crc := uint32(0) + for i := 0; i < len(data)-3; i++ { + crc = ((crc << 8) ^ crcTable[((crc>>16)^uint32(data[i]))&0xFF]) & 0xFFFFFF + } + + // Extract transmitted CRC from last 3 bytes + transmittedCRC := uint32(data[len(data)-3])<<16 | uint32(data[len(data)-2])<<8 | uint32(data[len(data)-1]) + + return crc == transmittedCRC +} + // Mode S Downlink Format (DF) constants. // The DF field (first 5 bits) determines the message type and structure. const ( @@ -126,6 +163,9 @@ type Decoder struct { cprOddLon map[uint32]float64 // Odd message longitude encoding (ICAO24 -> normalized lon) cprEvenTime map[uint32]int64 // Timestamp of even message (for freshness comparison) cprOddTime map[uint32]int64 // Timestamp of odd message (for freshness comparison) + + // Mutex to protect concurrent access to CPR maps + mu sync.RWMutex } // NewDecoder creates a new Mode S/ADS-B decoder with initialized CPR tracking. @@ -168,6 +208,11 @@ func (d *Decoder) Decode(data []byte) (*Aircraft, error) { return nil, fmt.Errorf("message too short: %d bytes", len(data)) } + // Validate CRC to reject corrupted messages that create ghost targets + if !validateModeSCRC(data) { + return nil, fmt.Errorf("invalid CRC - corrupted message") + } + df := (data[0] >> 3) & 0x1F icao := d.extractICAO(data, df) @@ -337,7 +382,8 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) { cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10]) oddFlag := (data[6] >> 2) & 0x01 - // Store CPR values for later decoding + // Store CPR values for later decoding (protected by mutex) + d.mu.Lock() if oddFlag == 1 { d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 @@ -345,6 +391,7 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) { d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 } + d.mu.Unlock() // Try to decode position if we have both even and odd messages d.decodeCPRPosition(aircraft) @@ -374,15 +421,23 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) { // Parameters: // - aircraft: Aircraft struct to update with decoded position func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) { + // Read CPR values with read lock + d.mu.RLock() evenLat, evenExists := d.cprEvenLat[aircraft.ICAO24] oddLat, oddExists := d.cprOddLat[aircraft.ICAO24] if !evenExists || !oddExists { + d.mu.RUnlock() return } evenLon := d.cprEvenLon[aircraft.ICAO24] oddLon := d.cprOddLon[aircraft.ICAO24] + d.mu.RUnlock() + + // Debug: Log CPR input values + fmt.Printf("CPR Debug %s: even=[%.6f,%.6f] odd=[%.6f,%.6f]\n", + aircraft.ICAO24, evenLat, evenLon, oddLat, oddLon) // CPR decoding algorithm dLat := 360.0 / 60.0 @@ -398,6 +453,25 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) { latOdd -= 360 } + // Additional range correction to ensure valid latitude bounds (-90° to +90°) + if latEven > 90 { + latEven = 180 - latEven + } else if latEven < -90 { + latEven = -180 - latEven + } + + if latOdd > 90 { + latOdd = 180 - latOdd + } else if latOdd < -90 { + latOdd = -180 - latOdd + } + + // Validate final latitude values are within acceptable range + if math.Abs(latOdd) > 90 || math.Abs(latEven) > 90 { + // Invalid CPR decoding - skip position update + return + } + // Choose the most recent position aircraft.Latitude = latOdd // Use odd for now, should check timestamps @@ -410,9 +484,20 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) { if lon >= 180 { lon -= 360 + } else if lon <= -180 { + lon += 360 + } + + // Validate longitude is within acceptable range + if math.Abs(lon) > 180 { + // Invalid longitude - skip position update + return } aircraft.Longitude = lon + + // Debug: Log final decoded coordinates + fmt.Printf("CPR Result %s: lat=%.6f lon=%.6f\n", aircraft.ICAO24, aircraft.Latitude, aircraft.Longitude) } // nlFunction calculates the number of longitude zones (NL) for a given latitude. @@ -486,6 +571,11 @@ func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) { // Calculate ground speed in knots (rounded to integer) speedKnots := math.Sqrt(ewVel*ewVel + nsVel*nsVel) + + // Validate speed range (0-600 knots for civilian aircraft) + if speedKnots > 600 { + speedKnots = 600 // Cap at reasonable maximum + } aircraft.GroundSpeed = int(math.Round(speedKnots)) // Calculate track in degrees (0-359) @@ -793,6 +883,8 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) { cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10]) oddFlag := (data[6] >> 2) & 0x01 + // Store CPR values for later decoding (protected by mutex) + d.mu.Lock() if oddFlag == 1 { d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 @@ -800,6 +892,7 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) { d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 } + d.mu.Unlock() d.decodeCPRPosition(aircraft) } diff --git a/internal/server/server.go b/internal/server/server.go index e972c49..618b458 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -183,6 +183,7 @@ func (s *Server) setupRoutes() http.Handler { api := router.PathPrefix("/api").Subrouter() 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("/sources", s.handleGetSources).Methods("GET") api.HandleFunc("/stats", s.handleGetStats).Methods("GET") api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET") @@ -203,29 +204,60 @@ func (s *Server) setupRoutes() http.Handler { return s.enableCORS(router) } +// isAircraftUseful determines if an aircraft has enough data to be useful for the frontend. +// +// DESIGN NOTE: We WANT reasonable aircraft to appear in our table view, even if they +// don't have enough data to appear on the map. This provides users visibility into +// all tracked aircraft, not just those with complete position data. +// +// Aircraft are considered useful if they have ANY of: +// - Valid position data (both latitude and longitude non-zero) -> Can show on map +// - Callsign (flight identification) -> Can show in table with "No position" status +// - Altitude information -> Can show in table as "Aircraft at X feet" +// - Any other identifying information that makes it a "real" aircraft +// +// This inclusive approach ensures the table view shows all aircraft we're tracking, +// while the map view only shows those with valid positions (handled by frontend filtering). +func (s *Server) isAircraftUseful(aircraft *merger.AircraftState) bool { + // Aircraft is useful if it has any meaningful data: + hasValidPosition := aircraft.Latitude != 0 && aircraft.Longitude != 0 + hasCallsign := aircraft.Callsign != "" + hasAltitude := aircraft.Altitude != 0 + hasSquawk := aircraft.Squawk != "" + + // Include aircraft with any identifying or operational data + return hasValidPosition || hasCallsign || hasAltitude || hasSquawk +} + // handleGetAircraft serves the /api/aircraft endpoint. // Returns all currently tracked aircraft with their latest state information. // +// Only "useful" aircraft are returned - those with position data or callsign. +// This filters out incomplete aircraft that only have altitude or squawk codes, +// which are not actionable for frontend mapping and flight tracking. +// // The response includes: // - timestamp: Unix timestamp of the response // - aircraft: Map of aircraft keyed by ICAO hex strings -// - count: Total number of aircraft +// - count: Total number of useful aircraft (filtered count) // // Aircraft ICAO addresses are converted from uint32 to 6-digit hex strings // for consistent JSON representation (e.g., 0xABC123 -> "ABC123"). func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) { aircraft := s.merger.GetAircraft() - // Convert ICAO keys to hex strings for JSON + // Convert ICAO keys to hex strings for JSON and filter useful aircraft aircraftMap := make(map[string]*merger.AircraftState) for icao, state := range aircraft { - aircraftMap[fmt.Sprintf("%06X", icao)] = state + if s.isAircraftUseful(state) { + aircraftMap[fmt.Sprintf("%06X", icao)] = state + } } response := map[string]interface{}{ "timestamp": time.Now().Unix(), "aircraft": aircraftMap, - "count": len(aircraft), + "count": len(aircraftMap), // Count of filtered useful aircraft } w.Header().Set("Content-Type", "application/json") @@ -478,10 +510,12 @@ func (s *Server) sendInitialData(conn *websocket.Conn) { sources := s.merger.GetSources() stats := s.merger.GetStatistics() - // Convert ICAO keys to hex strings + // Convert ICAO keys to hex strings and filter useful aircraft aircraftMap := make(map[string]*merger.AircraftState) for icao, state := range aircraft { - aircraftMap[fmt.Sprintf("%06X", icao)] = state + if s.isAircraftUseful(state) { + aircraftMap[fmt.Sprintf("%06X", icao)] = state + } } update := AircraftUpdate{ @@ -555,9 +589,10 @@ func (s *Server) periodicUpdateRoutine() { // // This function: // 1. Collects current aircraft data from the merger -// 2. Formats the data as a WebSocketMessage with type "aircraft_update" -// 3. Converts ICAO addresses to hex strings for JSON compatibility -// 4. Queues the message for broadcast (non-blocking) +// 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) // // If the broadcast channel is full, the update is dropped to prevent blocking. // This ensures the system continues operating even if WebSocket clients @@ -567,10 +602,12 @@ func (s *Server) broadcastUpdate() { sources := s.merger.GetSources() stats := s.merger.GetStatistics() - // Convert ICAO keys to hex strings + // Convert ICAO keys to hex strings and filter useful aircraft aircraftMap := make(map[string]*merger.AircraftState) for icao, state := range aircraft { - aircraftMap[fmt.Sprintf("%06X", icao)] = state + if s.isAircraftUseful(state) { + aircraftMap[fmt.Sprintf("%06X", icao)] = state + } } update := AircraftUpdate{ @@ -711,3 +748,34 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler { handler.ServeHTTP(w, r) }) } + +// handleDebugAircraft serves the /api/debug/aircraft endpoint. +// Returns all aircraft (filtered and unfiltered) for debugging position issues. +func (s *Server) handleDebugAircraft(w http.ResponseWriter, r *http.Request) { + aircraft := s.merger.GetAircraft() + + // All aircraft (unfiltered) + allAircraftMap := make(map[string]*merger.AircraftState) + for icao, state := range aircraft { + allAircraftMap[fmt.Sprintf("%06X", icao)] = state + } + + // Filtered aircraft (useful ones) + filteredAircraftMap := make(map[string]*merger.AircraftState) + for icao, state := range aircraft { + if s.isAircraftUseful(state) { + filteredAircraftMap[fmt.Sprintf("%06X", icao)] = state + } + } + + response := map[string]interface{}{ + "timestamp": time.Now().Unix(), + "all_aircraft": allAircraftMap, + "filtered_aircraft": filteredAircraftMap, + "all_count": len(allAircraftMap), + "filtered_count": len(filteredAircraftMap), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/main b/main new file mode 100755 index 0000000..595cbba Binary files /dev/null and b/main differ