diff --git a/.gitignore b/.gitignore index 902c831..a1bd4d4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,6 @@ skyview build/ dist/ -# Debian package build artifacts -debian/usr/bin/skyview -debian/usr/bin/beast-dump - # Configuration config.json diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 336d043..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,39 +0,0 @@ -# SkyView Project Guidelines - -## Documentation Requirements -- We should always have an up to date document describing our architecture and features -- Include links to any external resources we've used -- We should also always have an up to date README describing the project -- Shell scripts should be validated with shellcheck -- Always make sure the code is well documented with explanations for why and how a particular solution is selected - -## Development Principles -- An overarching principle with all code is KISS, Keep It Simple Stupid -- We do not want to create code that is more complicated than necessary -- When changing code, always make sure to update any relevant tests -- Use proper error handling - aviation applications need reliability - -## SkyView-Specific Guidelines - -### Architecture & Design -- Multi-source ADS-B data fusion is the core feature - prioritize signal strength-based conflict resolution -- Embedded resources (SQLite ICAO database, static assets) over external dependencies -- Low-latency performance is critical - optimize for fast WebSocket updates -- Support concurrent aircraft tracking (100+ aircraft should work smoothly) - -### Code Organization -- Keep Go packages focused: beast parsing, modes decoding, merger, server, clients -- Frontend should be modular: separate managers for aircraft, map, UI, websockets -- Database operations should be fast (use indexes, avoid N+1 queries) - -### Performance Considerations -- Beast binary parsing must handle high message rates (1000+ msg/sec per source) -- WebSocket broadcasting should not block on slow clients -- Memory usage should be bounded (configurable history limits) -- CPU usage should remain low during normal operation - -### Documentation Maintenance -- Always update docs/ARCHITECTURE.md when changing system design -- README.md should stay current with features and usage -- External resources (ICAO docs, ADS-B standards) should be linked in documentation -- Country database updates should be straightforward (replace SQLite file) \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 1e48ea9..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 SkyView Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile index 2f71042..b9914b1 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,12 @@ -PACKAGE_NAME=skyview +BINARY_NAME=skyview BUILD_DIR=build -VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -LDFLAGS=-w -s -X main.version=$(VERSION) -.PHONY: build build-all clean run dev test lint deb deb-clean install-deps +.PHONY: build clean run dev test lint -# Build main skyview binary build: - @echo "Building skyview..." + @echo "Building $(BINARY_NAME)..." @mkdir -p $(BUILD_DIR) - go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/skyview ./cmd/skyview - -# Build beast-dump utility binary -build-beast-dump: - @echo "Building beast-dump..." - @mkdir -p $(BUILD_DIR) - go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/beast-dump ./cmd/beast-dump - -# Build all binaries -build-all: build build-beast-dump - @echo "Built all binaries successfully:" - @ls -la $(BUILD_DIR)/ + go build -ldflags="-w -s" -o $(BUILD_DIR)/$(BINARY_NAME) . clean: @echo "Cleaning..." @@ -33,7 +19,7 @@ run: build dev: @echo "Running in development mode..." - go run ./cmd/skyview + go run main.go test: @echo "Running tests..." @@ -47,33 +33,6 @@ lint: echo "golangci-lint not installed, skipping lint"; \ fi -# Debian package targets -deb: - @echo "Building Debian package..." - @./scripts/build-deb.sh - -deb-clean: - @echo "Cleaning Debian package artifacts..." - @rm -f debian/usr/bin/skyview - @rm -rf build/*.deb - -deb-install: deb - @echo "Installing Debian package..." - @if [ "$$EUID" -ne 0 ]; then \ - echo "Please run as root: sudo make deb-install"; \ - exit 1; \ - fi - @dpkg -i build/skyview_*.deb || (apt-get update && apt-get -f install -y) - -deb-remove: - @echo "Removing Debian package..." - @if [ "$$EUID" -ne 0 ]; then \ - echo "Please run as root: sudo make deb-remove"; \ - exit 1; \ - fi - @dpkg -r skyview || true - -# Docker/Podman targets docker-build: @echo "Building Docker image..." docker build -t skyview . @@ -82,21 +41,8 @@ podman-build: @echo "Building Podman image..." podman build -t skyview . -# Development targets install-deps: @echo "Installing Go dependencies..." go mod tidy -format: - @echo "Formatting code..." - go fmt ./... - -vet: - @echo "Running go vet..." - go vet ./... - -# Combined quality check -check: format vet lint test - @echo "All checks passed!" - .DEFAULT_GOAL := build \ No newline at end of file diff --git a/README.md b/README.md index 2a3602b..a195869 100644 --- a/README.md +++ b/README.md @@ -1,268 +1,112 @@ -# SkyView - Multi-Source ADS-B Aircraft Tracker +# SkyView - ADS-B Aircraft Tracker -A high-performance, multi-source ADS-B aircraft tracking application that connects to multiple dump1090 Beast format TCP streams and provides a modern web interface with advanced visualization capabilities. +A modern web frontend for dump1090 ADS-B data with real-time aircraft tracking, statistics, and mobile-responsive design. -## ✨ Features +## Features -### Multi-Source Data Fusion -- **Beast Binary Format**: Native support for dump1090 Beast format (port 30005) -- **Multiple Receivers**: Connect to unlimited dump1090 sources simultaneously -- **Intelligent Merging**: Smart data fusion with signal strength-based source selection -- **High-throughput Processing**: High-performance concurrent message processing - -### Advanced Web Interface -- **Interactive Maps**: Leaflet.js-based mapping with aircraft tracking -- **Low-latency Updates**: WebSocket-powered live data streaming +- **Real-time Aircraft Tracking**: Live map with aircraft positions and flight paths +- **Interactive Map**: Leaflet-based map with aircraft markers and optional trails +- **Aircraft Table**: Sortable and filterable table view with detailed aircraft information +- **Statistics Dashboard**: Real-time statistics and charts for signal strength, aircraft counts +- **WebSocket Updates**: Real-time data updates without polling - **Mobile Responsive**: Optimized for desktop, tablet, and mobile devices -- **Multi-view Dashboard**: Map, Table, Statistics, Coverage, and 3D Radar views +- **Single Binary**: Embedded static files for easy deployment -### Professional Visualization -- **Signal Analysis**: Signal strength visualization and coverage analysis -- **Range Circles**: Configurable range rings for each receiver -- **Flight Trails**: Historical aircraft movement tracking -- **3D Radar View**: Three.js-powered 3D visualization -- **Statistics Dashboard**: Aircraft count timeline *(additional charts under construction)* 🚧 -- **Smart Origin**: Auto-calculated map center based on receiver locations -- **Map Controls**: Center on aircraft, reset to origin, toggle overlays -- **Signal Heatmaps**: Coverage heatmap visualization *(under construction)* 🚧 +## Configuration -### Aircraft Data -- **Complete Mode S Decoding**: Position, velocity, altitude, heading -- **Aircraft Identification**: Callsign, category, country, registration -- **ICAO Country Database**: Comprehensive embedded database with 70+ allocations covering 40+ countries -- **Multi-source Tracking**: Signal strength from each receiver -- **Historical Data**: Position history and trail visualization +### Environment Variables -## 🚀 Quick Start +- `SKYVIEW_ADDRESS`: Server listen address (default: ":8080") +- `SKYVIEW_PORT`: Server port (default: 8080) +- `DUMP1090_HOST`: dump1090 host address (default: "localhost") +- `DUMP1090_DATA_PORT`: dump1090 SBS-1 data port (default: 30003) +- `ORIGIN_LATITUDE`: Receiver latitude for distance calculations (default: 37.7749) +- `ORIGIN_LONGITUDE`: Receiver longitude for distance calculations (default: -122.4194) +- `ORIGIN_NAME`: Name/description of receiver location (default: "Default Location") +- `SKYVIEW_CONFIG`: Path to JSON configuration file -### Using Command Line +### Configuration File -```bash -# Single source -./skyview -sources "primary:Local:localhost:30005:51.47:-0.46" +SkyView automatically loads `config.json` from the current directory, or you can specify a path with `SKYVIEW_CONFIG`. -# Multiple sources -./skyview -sources "site1:North:192.168.1.100:30005:51.50:-0.46,site2:South:192.168.1.101:30005:51.44:-0.46" - -# Using configuration file -./skyview -config config.json -``` - -### Using Debian Package - -```bash -# Install -sudo dpkg -i skyview_0.0.2_amd64.deb - -# Configure -sudo nano /etc/skyview/config.json - -# Start service -sudo systemctl start skyview -sudo systemctl enable skyview -``` - -## ⚙️ Configuration - -### Configuration File Structure +Create a `config.json` file (see `config.json.example`): ```json { "server": { - "host": "", + "address": ":8080", "port": 8080 }, - "sources": [ - { - "id": "primary", - "name": "Primary Receiver", - "host": "localhost", - "port": 30005, - "latitude": 51.4700, - "longitude": -0.4600, - "altitude": 50.0, - "enabled": true - } - ], - "settings": { - "history_limit": 1000, - "stale_timeout": 60, - "update_rate": 1 - }, - "origin": { - "latitude": 51.4700, - "longitude": -0.4600, - "name": "Custom Origin" + "dump1090": { + "host": "192.168.1.100", + "data_port": 30003 } } ``` -### Command Line Options +### Data Source + +SkyView uses **SBS-1/BaseStation format** (Port 30003) which provides decoded aircraft information including: +- Aircraft position (latitude/longitude) +- Altitude, ground speed, vertical rate +- Flight number/callsign +- Squawk code and emergency status + +## Building and Running + +### Build ```bash -skyview [options] - -Options: - -config string - Configuration file path - -server string - Server address (default ":8080") - -sources string - Comma-separated Beast sources (id:name:host:port:lat:lon) - -verbose - Enable verbose logging +go build -o skyview . ``` -## 🗺️ Web Interface - -Access the web interface at `http://localhost:8080` - -### Views Available: -- **Map View**: Interactive aircraft tracking with receiver locations -- **Table View**: Sortable aircraft data with multi-source information -- **Statistics**: Aircraft count timeline *(additional charts planned)* 🚧 -- **Coverage**: Signal strength analysis *(heatmaps under construction)* 🚧 -- **3D Radar**: Three-dimensional aircraft visualization *(controls under construction)* 🚧 - -### 🚧 Features Under Construction -Some advanced features are currently in development: -- **Message Rate Charts**: Per-source message rate visualization -- **Signal Strength Distribution**: Signal strength histogram analysis -- **Altitude Distribution**: Aircraft altitude distribution charts -- **Interactive Heatmaps**: Leaflet.heat-based coverage heatmaps -- **3D Radar Controls**: Interactive 3D view manipulation (reset, auto-rotate, range) -- **Enhanced Error Notifications**: User-friendly toast notifications for issues - -## 🔧 Building - -### Prerequisites -- Go 1.21 or later -- Make - -### Build Commands +### Run ```bash -make build # Build binary -make deb # Create Debian package -make docker-build # Build Docker image -make test # Run tests -make clean # Clean artifacts +# Foreground (default) - Press Ctrl+C to stop +DUMP1090_HOST=192.168.1.100 ./skyview + +# Daemon mode (background process) +DUMP1090_HOST=192.168.1.100 ./skyview -daemon + +# With custom origin location +DUMP1090_HOST=192.168.1.100 ORIGIN_LATITUDE=59.3293 ORIGIN_LONGITUDE=18.0686 ORIGIN_NAME="Stockholm" ./skyview + +# Using config file +SKYVIEW_CONFIG=config.json ./skyview + +# Default (localhost:30003) +./skyview ``` -## 🐳 Docker +### Development ```bash -# Build -make docker-build - -# Run -docker run -p 8080:8080 -v $(pwd)/config.json:/app/config.json skyview +go run main.go ``` -## 📊 API Reference +## Usage -### REST Endpoints -- `GET /api/aircraft` - All aircraft data -- `GET /api/aircraft/{icao}` - Individual aircraft details -- `GET /api/sources` - Data source information -- `GET /api/stats` - System statistics -- `GET /api/origin` - Map origin configuration -- `GET /api/coverage/{sourceId}` - Coverage analysis -- `GET /api/heatmap/{sourceId}` - Signal heatmap +1. Start your dump1090 instance +2. Configure SkyView to point to your dump1090 host +3. Run SkyView +4. Open your browser to `http://localhost:8080` -### WebSocket -- `ws://localhost:8080/ws` - Low-latency updates +## API Endpoints -## 🛠️ Development +- `GET /`: Main web interface +- `GET /api/aircraft`: Aircraft data (parsed from dump1090 TCP stream) +- `GET /api/stats`: Statistics data (calculated from aircraft data) +- `GET /ws`: WebSocket endpoint for real-time updates -### Project Structure -``` -skyview/ -├── cmd/skyview/ # Main application -├── assets/ # Embedded static web assets -├── internal/ -│ ├── beast/ # Beast format parser -│ ├── modes/ # Mode S decoder -│ ├── merger/ # Multi-source merger -│ ├── client/ # Beast TCP clients -│ └── server/ # HTTP/WebSocket server -├── debian/ # Debian packaging -└── scripts/ # Build scripts -``` +## Data Sources -### Development Commands -```bash -make dev # Run in development mode -make format # Format code -make lint # Run linter -make check # Run all checks -``` +SkyView connects to dump1090's **SBS-1/BaseStation format** via TCP port 30003 to receive decoded aircraft data in real-time. -## 📦 Deployment +The application maintains an in-memory aircraft database with automatic cleanup of stale aircraft (older than 2 minutes). -### Systemd Service (Debian/Ubuntu) -```bash -# Install package -sudo dpkg -i skyview_0.0.2_amd64.deb +## Views -# Configure sources in /etc/skyview/config.json -# Start service -sudo systemctl start skyview -sudo systemctl enable skyview - -# Check status -sudo systemctl status skyview -sudo journalctl -u skyview -f -``` - -### Manual Installation -```bash -# Build binary -make build - -# Create user and directories -sudo useradd -r -s /bin/false skyview -sudo mkdir -p /etc/skyview /var/lib/skyview /var/log/skyview -sudo chown skyview:skyview /var/lib/skyview /var/log/skyview - -# Install binary and config -sudo cp build/skyview /usr/bin/ -sudo cp config.example.json /etc/skyview/config.json -sudo chown root:skyview /etc/skyview/config.json -sudo chmod 640 /etc/skyview/config.json - -# Create systemd service -sudo cp debian/lib/systemd/system/skyview.service /lib/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable skyview -sudo systemctl start skyview -``` - -## 🔒 Security - -The application includes security hardening: -- Runs as unprivileged user -- Restricted filesystem access -- Network isolation where possible -- Systemd security features enabled - -## 📄 License - -MIT License - see [LICENSE](LICENSE) file for details. - -## 🤝 Contributing - -1. Fork the repository -2. Create feature branch -3. Make changes with tests -4. Submit pull request - -## 🆘 Support - -- [Issues](https://kode.naiv.no/olemd/skyview/issues) -- [Documentation](https://kode.naiv.no/olemd/skyview/wiki) -- [Configuration Examples](https://kode.naiv.no/olemd/skyview/src/branch/main/examples) - ---- - -**SkyView** - Professional multi-source ADS-B tracking with Beast format support. \ No newline at end of file +- **Map View**: Interactive map with aircraft positions and trails +- **Table View**: Sortable table with aircraft details and search +- **Stats View**: Dashboard with real-time statistics and charts \ No newline at end of file diff --git a/assets/assets.go b/assets/assets.go deleted file mode 100644 index 54e1c4a..0000000 --- a/assets/assets.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package assets provides embedded static web assets for the SkyView application. -// -// This package uses Go 1.16+ embed functionality to include all static web files -// directly in the compiled binary, eliminating the need for external file dependencies -// at runtime. The embedded assets include: -// - index.html: Main web interface with aircraft tracking map -// - css/style.css: Styling for the web interface -// - js/app.js: JavaScript client for WebSocket communication and map rendering -// - icons/*.svg: Type-specific SVG icons for aircraft markers -// - favicon.ico: Browser icon -// -// The embedded filesystem is used by the HTTP server to serve static content -// and enables single-binary deployment without external asset dependencies. -package assets - -import "embed" - -// Static contains all embedded static web assets from the static/ directory. -// -// Files are embedded at build time and can be accessed using the standard -// fs.FS interface. Path names within the embedded filesystem preserve the -// directory structure, so files are accessed as: -// - "static/index.html" -// - "static/css/style.css" -// - "static/js/app.js" -// - etc. -// -// This approach ensures the web interface is always available without requiring -// external file deployment or complicated asset management. -// -//go:embed static -var Static embed.FS diff --git a/assets/static/icons/cargo.svg b/assets/static/icons/cargo.svg deleted file mode 100644 index 6d30370..0000000 --- a/assets/static/icons/cargo.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/icons/commercial.svg b/assets/static/icons/commercial.svg deleted file mode 100644 index f193d6a..0000000 --- a/assets/static/icons/commercial.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/icons/ga.svg b/assets/static/icons/ga.svg deleted file mode 100644 index 2be9e7a..0000000 --- a/assets/static/icons/ga.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/icons/ground.svg b/assets/static/icons/ground.svg deleted file mode 100644 index 96c03a3..0000000 --- a/assets/static/icons/ground.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/icons/helicopter.svg b/assets/static/icons/helicopter.svg deleted file mode 100644 index b1c90bc..0000000 --- a/assets/static/icons/helicopter.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/icons/military.svg b/assets/static/icons/military.svg deleted file mode 100644 index 931c9c8..0000000 --- a/assets/static/icons/military.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/index.html b/assets/static/index.html deleted file mode 100644 index d8eeaac..0000000 --- a/assets/static/index.html +++ /dev/null @@ -1,265 +0,0 @@ - - - - - - SkyView - Multi-Source ADS-B Aircraft Tracker - - - - - - - - - - - - - - - -
-
-

SkyView v0.0.2

- - -
-
-
-
-
-
-
-
UTC
-
-
-
-
-
-
-
Last Update
-
-
-
- - -
- 0 aircraft - 0 sources - Connecting... -
-
- -
- -
- - - - - -
- - -
-
- - -
- - - - - -
- - -
- - -
- - -
-

ADS-B Categories

-
- - Light < 7000kg -
-
- - Medium 7000-34000kg -
-
- - Large 34000-136000kg -
-
- - High Vortex Large -
-
- - Heavy > 136000kg -
-
- - Rotorcraft -
-
- - Glider/Ultralight -
-
- - Surface Vehicle -
- -

Sources

-
-
-
- - -
-
- - - -
-
- - - - - - - - - - - - - - - - - -
ICAOFlightSquawkAltitudeSpeedDistanceTrackSourcesSignalAge
-
-
- - -
-
-
-

Total Aircraft

-
0
-
-
-

Active Sources

-
0
-
-
-

Messages/sec

-
0
-
-
-

Max Range

-
0 km
-
-
- - -
-
-

Aircraft Count Timeline

- -
-
-

Message Rate by Source 🚧 Under Construction

- -
This chart is planned but not yet implemented
-
-
-

Signal Strength Distribution 🚧 Under Construction

- -
This chart is planned but not yet implemented
-
-
-

Altitude Distribution 🚧 Under Construction

- -
This chart is planned but not yet implemented
-
-
-
- - -
-
- - -
-
-
- - -
-
-
🚧 3D Controls Under Construction
- - - -
-
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/assets/static/js/app.js b/assets/static/js/app.js deleted file mode 100644 index f396a5f..0000000 --- a/assets/static/js/app.js +++ /dev/null @@ -1,506 +0,0 @@ -// 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() { - // Initialize managers - this.wsManager = null; - this.aircraftManager = null; - this.mapManager = null; - this.uiManager = null; - - // 3D Radar - this.radar3d = null; - - // Charts - this.charts = {}; - - // Selected aircraft tracking - this.selectedAircraft = null; - this.selectedTrailEnabled = false; - - this.init(); - } - - async init() { - try { - - // 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); - - // Set up selected aircraft trail callback - this.aircraftManager.setSelectedAircraftCallback((icao) => { - return this.selectedTrailEnabled && this.selectedAircraft === icao; - }); - - // 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(); - - } 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 toggleSourcesBtn = document.getElementById('toggle-sources'); - - if (centerMapBtn) { - centerMapBtn.addEventListener('click', () => { - this.aircraftManager.centerMapOnAircraft(() => this.mapManager.getSourcePositions()); - }); - } - - 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 (toggleSourcesBtn) { - toggleSourcesBtn.addEventListener('click', () => { - const showSources = this.mapManager.toggleSources(); - toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources'; - }); - } - - // Setup collapsible sections - this.setupCollapsibleSections(); - - const toggleDarkModeBtn = document.getElementById('toggle-dark-mode'); - if (toggleDarkModeBtn) { - toggleDarkModeBtn.addEventListener('click', () => { - const isDarkMode = this.mapManager.toggleDarkMode(); - toggleDarkModeBtn.innerHTML = isDarkMode ? '☀️ Light Mode' : '🌙 Night Mode'; - }); - } - - // 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(); - }); - } - - // Display option checkboxes - const sitePositionsCheckbox = document.getElementById('show-site-positions'); - const rangeRingsCheckbox = document.getElementById('show-range-rings'); - const selectedTrailCheckbox = document.getElementById('show-selected-trail'); - - if (sitePositionsCheckbox) { - sitePositionsCheckbox.addEventListener('change', (e) => { - if (e.target.checked) { - this.mapManager.showSources = true; - this.mapManager.updateSourceMarkers(); - } else { - this.mapManager.showSources = false; - this.mapManager.sourceMarkers.forEach(marker => this.mapManager.map.removeLayer(marker)); - this.mapManager.sourceMarkers.clear(); - } - }); - } - - if (rangeRingsCheckbox) { - rangeRingsCheckbox.addEventListener('change', (e) => { - if (e.target.checked) { - this.mapManager.showRange = true; - this.mapManager.updateRangeCircles(); - } else { - this.mapManager.showRange = false; - this.mapManager.rangeCircles.forEach(circle => this.mapManager.map.removeLayer(circle)); - this.mapManager.rangeCircles.clear(); - } - }); - } - - if (selectedTrailCheckbox) { - selectedTrailCheckbox.addEventListener('change', (e) => { - this.selectedTrailEnabled = e.target.checked; - if (!e.target.checked && this.selectedAircraft) { - // Hide currently selected aircraft trail - this.aircraftManager.hideAircraftTrail(this.selectedAircraft); - } else if (e.target.checked && this.selectedAircraft) { - // Show currently selected aircraft trail - this.aircraftManager.showAircraftTrail(this.selectedAircraft); - } - }); - } - } - - setupAircraftSelection() { - document.addEventListener('aircraftSelected', (e) => { - const { icao, aircraft } = e.detail; - this.uiManager.switchView('map-view'); - - // Hide trail for previously selected aircraft - if (this.selectedAircraft && this.selectedTrailEnabled) { - this.aircraftManager.hideAircraftTrail(this.selectedAircraft); - } - - // Update selected aircraft - this.selectedAircraft = icao; - - // Automatically enable selected aircraft trail when an aircraft is selected - if (!this.selectedTrailEnabled) { - this.selectedTrailEnabled = true; - const selectedTrailCheckbox = document.getElementById('show-selected-trail'); - if (selectedTrailCheckbox) { - selectedTrailCheckbox.checked = true; - } - } - - // Show trail for newly selected aircraft - this.aircraftManager.showAircraftTrail(icao); - - // 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': - 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: - } - } - - 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(); - - // Clear selected aircraft if it no longer exists - if (this.selectedAircraft && !this.aircraftManager.aircraftData.has(this.selectedAircraft)) { - this.selectedAircraft = null; - } - - // 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(); - - } 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); - } - - setupCollapsibleSections() { - // Setup Display Options collapsible - const displayHeader = document.getElementById('display-options-header'); - const displayContent = document.getElementById('display-options-content'); - - if (displayHeader && displayContent) { - displayHeader.addEventListener('click', () => { - const isCollapsed = displayContent.classList.contains('collapsed'); - - if (isCollapsed) { - // Expand - displayContent.classList.remove('collapsed'); - displayHeader.classList.remove('collapsed'); - } else { - // Collapse - displayContent.classList.add('collapsed'); - displayHeader.classList.add('collapsed'); - } - - // Save state to localStorage - localStorage.setItem('displayOptionsCollapsed', !isCollapsed); - }); - - // Restore saved state (default to collapsed) - const savedState = localStorage.getItem('displayOptionsCollapsed'); - const shouldCollapse = savedState === null ? true : savedState === 'true'; - - if (shouldCollapse) { - displayContent.classList.add('collapsed'); - displayHeader.classList.add('collapsed'); - } else { - displayContent.classList.remove('collapsed'); - displayHeader.classList.remove('collapsed'); - } - } - } -} - -// 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/modules/aircraft-manager.js b/assets/static/js/modules/aircraft-manager.js deleted file mode 100644 index 128e791..0000000 --- a/assets/static/js/modules/aircraft-manager.js +++ /dev/null @@ -1,527 +0,0 @@ -// 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; - - // SVG icon cache - this.iconCache = new Map(); - this.loadIcons(); - - // Selected aircraft trail tracking - this.selectedAircraftCallback = null; - - // Map event listeners removed - let Leaflet handle positioning naturally - } - - async loadIcons() { - const iconTypes = ['commercial', 'helicopter', 'military', 'cargo', 'ga', 'ground']; - - for (const type of iconTypes) { - try { - const response = await fetch(`/static/icons/${type}.svg`); - const svgText = await response.text(); - this.iconCache.set(type, svgText); - } catch (error) { - console.warn(`Failed to load icon for ${type}:`, error); - // Fallback to inline SVG if needed - this.iconCache.set(type, this.createFallbackIcon(type)); - } - } - } - - createFallbackIcon(type) { - // Fallback inline SVG if file loading fails - const color = 'currentColor'; - let path = ''; - - switch (type) { - case 'helicopter': - path = ` - - - `; - break; - case 'military': - path = ``; - break; - case 'cargo': - path = ` - `; - break; - case 'ga': - path = ``; - break; - case 'ground': - path = ` - - `; - break; - default: - path = ``; - } - - return ` - - - ${path} - -`; - } - - updateAircraftData(data) { - if (data.aircraft) { - this.aircraftData.clear(); - for (const [icao, aircraft] of Object.entries(data.aircraft)) { - this.aircraftData.set(icao, 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); - - // Remove trail if it exists - if (this.aircraftTrails.has(icao)) { - const trail = this.aircraftTrails.get(icao); - if (trail.polyline) { - this.map.removeLayer(trail.polyline); - } - this.aircraftTrails.delete(icao); - } - - // Notify if this was the selected aircraft - if (this.selectedAircraftCallback && this.selectedAircraftCallback(icao)) { - // Aircraft was selected and disappeared - could notify main app - // For now, the callback will return false automatically since selectedAircraft will be cleared - } - - 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]; - - - // 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 - const oldPos = marker.getLatLng(); - marker.setLatLng(pos); - - // 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; - const typeChanged = currentType !== newType; - const groundStatusChanged = currentOnGround !== aircraft.OnGround; - - if (rotationChanged || typeChanged || groundStatusChanged) { - marker.setIcon(this.createAircraftIcon(aircraft)); - marker._currentRotation = aircraft.Track || 0; - marker._currentType = newType; - marker._currentOnGround = aircraft.OnGround || false; - } - - // Handle popup exactly like Leaflet expects - if (marker.isPopupOpen()) { - marker.setPopupContent(this.createPopupContent(aircraft)); - } - - this.markerUpdateCount++; - - } else { - // Create new marker - const icon = this.createAircraftIcon(aircraft); - - try { - const marker = L.marker(pos, { - icon: icon - }).addTo(this.map); - - // Store current properties for future update comparisons - marker._currentRotation = aircraft.Track || 0; - marker._currentType = this.getAircraftIconType(aircraft); - marker._currentOnGround = aircraft.OnGround || false; - - marker.bindPopup(this.createPopupContent(aircraft), { - maxWidth: 450, - className: 'aircraft-popup' - }); - - this.aircraftMarkers.set(icao, marker); - this.markerCreateCount++; - - // Force immediate visibility - if (marker._icon) { - marker._icon.style.display = 'block'; - marker._icon.style.opacity = '1'; - marker._icon.style.visibility = 'visible'; - } - } catch (error) { - console.error(`Failed to create marker for ${icao}:`, error); - } - } - - // Update trails - check both global trails and individual selected aircraft - if (this.showTrails || this.isSelectedAircraftTrailEnabled(icao)) { - this.updateAircraftTrail(icao, aircraft); - } - } - - createAircraftIcon(aircraft) { - const iconType = this.getAircraftIconType(aircraft); - const color = this.getAircraftColor(iconType, aircraft); - const size = aircraft.OnGround ? 12 : 16; - const rotation = aircraft.Track || 0; - - // Get SVG template from cache - let svgTemplate = this.iconCache.get(iconType) || this.iconCache.get('commercial'); - - if (!svgTemplate) { - // Ultimate fallback - create a simple circle - svgTemplate = ` - - - - `; - } - - // Apply color and rotation to the SVG - let svg = svgTemplate - .replace(/currentColor/g, color) - .replace(/width="32"/, `width="${size * 2}"`) - .replace(/height="32"/, `height="${size * 2}"`); - - // Add rotation to the transform - if (rotation !== 0) { - svg = svg.replace(/transform="translate\(16,16\)"/, `transform="translate(16,16) rotate(${rotation})"`); - } - - return L.divIcon({ - html: svg, - iconSize: [size * 2, size * 2], - iconAnchor: [size, size], - className: 'aircraft-marker' - }); - } - - getAircraftType(aircraft) { - // For display purposes, return the actual ADS-B category - // This is used in the popup display - if (aircraft.OnGround) return 'On Ground'; - if (aircraft.Category) return aircraft.Category; - return 'Unknown'; - } - - getAircraftIconType(aircraft) { - // For icon selection, we still need basic categories - // This determines which SVG shape to use - if (aircraft.OnGround) return 'ground'; - - if (aircraft.Category) { - const cat = aircraft.Category.toLowerCase(); - - // Map to basic icon types for visual representation - if (cat.includes('helicopter') || cat.includes('rotorcraft')) return 'helicopter'; - if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military'; - if (cat.includes('cargo') || cat.includes('heavy') || cat.includes('super')) return 'cargo'; - if (cat.includes('light') || cat.includes('glider') || cat.includes('ultralight')) return 'ga'; - } - - // Default commercial icon for everything else - return 'commercial'; - } - - getAircraftColor(type, aircraft) { - // Special colors for specific types - if (type === 'military') return '#ff4444'; - if (type === 'helicopter') return '#ff00ff'; - if (type === 'ground') return '#888888'; - if (type === 'ga') return '#ffff00'; - - // For commercial and cargo types, use weight-based colors - if (aircraft && aircraft.Category) { - const cat = aircraft.Category.toLowerCase(); - - // Check for specific weight ranges in the category string - // Light aircraft (< 7000kg) - Sky blue - if (cat.includes('light')) { - return '#00bfff'; - } - // Medium aircraft (7000-34000kg) - Green - if (cat.includes('medium')) { - return '#00ff88'; - } - // High Vortex Large - Red-orange (special wake turbulence category) - if (cat.includes('high vortex')) { - return '#ff4500'; - } - // Large aircraft (34000-136000kg) - Orange - if (cat.includes('large')) { - return '#ff8c00'; - } - // Heavy aircraft (> 136000kg) - Red - if (cat.includes('heavy') || cat.includes('super')) { - return '#ff0000'; - } - } - - // Default to green for unknown commercial aircraft - return '#00ff88'; - } - - - updateAircraftTrail(icao, aircraft) { - // Use server-provided position history - if (!aircraft.position_history || aircraft.position_history.length < 2) { - // No trail data available or not enough points - if (this.aircraftTrails.has(icao)) { - const trail = this.aircraftTrails.get(icao); - if (trail.polyline) { - this.map.removeLayer(trail.polyline); - } - this.aircraftTrails.delete(icao); - } - return; - } - - // Convert position history to Leaflet format - const trailPoints = aircraft.position_history.map(point => [point.lat, point.lon]); - - // Get or create trail object - if (!this.aircraftTrails.has(icao)) { - this.aircraftTrails.set(icao, {}); - } - const trail = this.aircraftTrails.get(icao); - - // Remove old polyline if it exists - if (trail.polyline) { - this.map.removeLayer(trail.polyline); - } - - // Create gradient effect - newer points are brighter - const segments = []; - for (let i = 1; i < trailPoints.length; i++) { - const opacity = 0.2 + (0.6 * (i / trailPoints.length)); // Fade from 0.2 to 0.8 - const segment = L.polyline([trailPoints[i-1], trailPoints[i]], { - color: '#00d4ff', - weight: 2, - opacity: opacity - }); - segments.push(segment); - } - - // Create a feature group for all segments - trail.polyline = L.featureGroup(segments).addTo(this.map); - } - - createPopupContent(aircraft) { - const type = this.getAircraftType(aircraft); - const country = aircraft.country || 'Unknown'; - const flag = aircraft.flag || '🏳️'; - - 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; - } - - - 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; - } - - showAircraftTrail(icao) { - const aircraft = this.aircraftData.get(icao); - if (aircraft && aircraft.position_history && aircraft.position_history.length >= 2) { - this.updateAircraftTrail(icao, aircraft); - } - } - - hideAircraftTrail(icao) { - if (this.aircraftTrails.has(icao)) { - const trail = this.aircraftTrails.get(icao); - if (trail.polyline) { - this.map.removeLayer(trail.polyline); - } - this.aircraftTrails.delete(icao); - } - } - - setSelectedAircraftCallback(callback) { - this.selectedAircraftCallback = callback; - } - - isSelectedAircraftTrailEnabled(icao) { - return this.selectedAircraftCallback && this.selectedAircraftCallback(icao); - } - - centerMapOnAircraft(includeSourcesCallback = null) { - const validAircraft = Array.from(this.aircraftData.values()) - .filter(a => a.Latitude && a.Longitude); - - const allPoints = []; - - // Add aircraft positions - validAircraft.forEach(a => { - allPoints.push([a.Latitude, a.Longitude]); - }); - - // Add source positions if callback provided - if (includeSourcesCallback && typeof includeSourcesCallback === 'function') { - const sourcePositions = includeSourcesCallback(); - allPoints.push(...sourcePositions); - } - - if (allPoints.length === 0) return; - - if (allPoints.length === 1) { - // Center on single point - this.map.setView(allPoints[0], 12); - } else { - // Fit bounds to all points (aircraft + sources) - const bounds = L.latLngBounds(allPoints); - this.map.fitBounds(bounds.pad(0.1)); - } - } - -} \ No newline at end of file diff --git a/assets/static/js/modules/map-manager.js b/assets/static/js/modules/map-manager.js deleted file mode 100644 index bd2ac75..0000000 --- a/assets/static/js/modules/map-manager.js +++ /dev/null @@ -1,378 +0,0 @@ -// 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(); - - // Map theme - this.isDarkMode = false; - this.currentTileLayer = null; - this.coverageTileLayer = null; - } - - 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); - - // Light tile layer by default - this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19 - }).addTo(this.map); - - // Add scale control for distance estimation - L.control.scale({ - metric: true, - imperial: true, - position: 'bottomright' - }).addTo(this.map); - - 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); - - this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(this.coverageMap); - - // Add scale control for distance estimation - L.control.scale({ - metric: true, - imperial: true, - position: 'bottomright' - }).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: 2, - opacity: 0.7 - (index * 0.15), - dashArray: '8,4' - }).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; - } - - toggleDarkMode() { - this.isDarkMode = !this.isDarkMode; - - const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; - const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; - const tileUrl = this.isDarkMode ? darkUrl : lightUrl; - - const tileOptions = { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19 - }; - - // Update main map - if (this.map && this.currentTileLayer) { - this.map.removeLayer(this.currentTileLayer); - this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map); - } - - // Update coverage map - if (this.coverageMap && this.coverageTileLayer) { - this.coverageMap.removeLayer(this.coverageTileLayer); - this.coverageTileLayer = L.tileLayer(tileUrl, { - attribution: '© OpenStreetMap contributors' - }).addTo(this.coverageMap); - } - - return this.isDarkMode; - } - - // 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) { - // 🚧 Under Construction: Heatmap visualization not yet implemented - // Planned: Use Leaflet.heat library for proper heatmap rendering - console.log('Heatmap overlay requested but not yet implemented'); - - // Show user-visible notice - if (window.uiManager) { - window.uiManager.showError('Heatmap visualization is under construction 🚧'); - } - } - - setSelectedSource(sourceId) { - this.selectedSource = sourceId; - } - - getSourcePositions() { - const positions = []; - for (const [id, source] of this.sourcesData) { - if (source.latitude && source.longitude) { - positions.push([source.latitude, source.longitude]); - } - } - return positions; - } -} \ No newline at end of file diff --git a/assets/static/js/modules/ui-manager.js b/assets/static/js/modules/ui-manager.js deleted file mode 100644 index c4e7569..0000000 --- a/assets/static/js/modules/ui-manager.js +++ /dev/null @@ -1,337 +0,0 @@ -// 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); - - // Simple toast notification implementation - const toast = document.createElement('div'); - toast.className = 'toast-notification error'; - toast.textContent = message; - - // Add to page - document.body.appendChild(toast); - - // Show toast with animation - setTimeout(() => toast.classList.add('show'), 100); - - // Auto-remove after 5 seconds - setTimeout(() => { - toast.classList.remove('show'); - setTimeout(() => document.body.removeChild(toast), 300); - }, 5000); - } -} \ No newline at end of file diff --git a/assets/static/js/modules/websocket.js b/assets/static/js/modules/websocket.js deleted file mode 100644 index 5fa733c..0000000 --- a/assets/static/js/modules/websocket.js +++ /dev/null @@ -1,54 +0,0 @@ -// 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 = () => { - this.onStatusChange('connected'); - }; - - this.websocket.onclose = () => { - 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); - - - 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 deleted file mode 100644 index 8bd7a8b..0000000 --- a/cmd/beast-dump/main.go +++ /dev/null @@ -1,442 +0,0 @@ -// 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(0.0, 0.0), // beast-dump doesn't have reference position, use default - 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) - } - } -} diff --git a/config.example.json b/config.example.json deleted file mode 100644 index f275347..0000000 --- a/config.example.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "server": { - "host": "", - "port": 8080 - }, - "sources": [ - { - "id": "primary", - "name": "Primary Site", - "host": "localhost", - "port": 30005, - "latitude": 51.4700, - "longitude": -0.4600, - "altitude": 50.0, - "enabled": true - }, - { - "id": "secondary", - "name": "Secondary Site", - "host": "192.168.1.100", - "port": 30005, - "latitude": 51.4800, - "longitude": -0.4500, - "altitude": 45.0, - "enabled": true - }, - { - "id": "remote", - "name": "Remote Site", - "host": "remote.example.com", - "port": 30005, - "latitude": 51.4900, - "longitude": -0.4400, - "altitude": 60.0, - "enabled": false - } - ], - "origin": { - "latitude": 51.4700, - "longitude": -0.4600, - "name": "Control Tower" - }, - "settings": { - "history_limit": 1000, - "stale_timeout": 60, - "update_rate": 1 - } -} \ No newline at end of file diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..15dbc78 --- /dev/null +++ b/config.json.example @@ -0,0 +1,15 @@ +{ + "server": { + "address": ":8080", + "port": 8080 + }, + "dump1090": { + "host": "192.168.1.100", + "data_port": 30003 + }, + "origin": { + "latitude": 37.7749, + "longitude": -122.4194, + "name": "San Francisco" + } +} \ No newline at end of file diff --git a/debian/DEBIAN/control b/debian/DEBIAN/control deleted file mode 100644 index 0d87129..0000000 --- a/debian/DEBIAN/control +++ /dev/null @@ -1,23 +0,0 @@ -Package: skyview -Version: 0.0.2 -Section: net -Priority: optional -Architecture: amd64 -Depends: systemd -Maintainer: Ole-Morten Duesund -Description: Multi-source ADS-B aircraft tracker with Beast format support - SkyView is a standalone application that connects to multiple dump1090 Beast - format TCP streams and provides a modern web frontend for aircraft tracking. - Features include real-time aircraft tracking, signal strength analysis, - coverage mapping, and 3D radar visualization. - . - Key features: - - Multi-source Beast binary format parsing - - Real-time WebSocket updates - - Interactive maps with Leaflet.js - - Signal strength heatmaps and range circles - - Historical flight tracking - - Mobile-responsive design - - Systemd integration for service management - - Beast-dump utility for raw ADS-B data analysis -Homepage: https://kode.naiv.no/olemd/skyview diff --git a/debian/DEBIAN/postinst b/debian/DEBIAN/postinst deleted file mode 100755 index c99d832..0000000 --- a/debian/DEBIAN/postinst +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -set -e - -case "$1" in - configure) - # Create skyview user and group if they don't exist - if ! getent group skyview >/dev/null 2>&1; then - addgroup --system --quiet skyview - fi - - if ! getent passwd skyview >/dev/null 2>&1; then - adduser --system --ingroup skyview --home /var/lib/skyview \ - --no-create-home --disabled-password --quiet skyview - fi - - # Create directories with proper permissions - mkdir -p /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true - chown skyview:skyview /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true - chmod 755 /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true - - # Set permissions on config files - if [ -f /etc/skyview/config.json ]; then - chown root:skyview /etc/skyview/config.json >/dev/null 2>&1 || true - chmod 640 /etc/skyview/config.json >/dev/null 2>&1 || true - fi - - - # Handle systemd service - systemctl daemon-reload >/dev/null 2>&1 || true - - # Check if service was previously enabled - if systemctl is-enabled skyview >/dev/null 2>&1; then - # Service was enabled, restart it - systemctl restart skyview >/dev/null 2>&1 || true - fi - ;; -esac - -exit 0 \ No newline at end of file diff --git a/debian/DEBIAN/postrm b/debian/DEBIAN/postrm deleted file mode 100755 index 68f0fe7..0000000 --- a/debian/DEBIAN/postrm +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -e - -case "$1" in - purge) - # Remove user and group - if getent passwd skyview >/dev/null 2>&1; then - deluser --system skyview >/dev/null 2>&1 || true - fi - - if getent group skyview >/dev/null 2>&1; then - delgroup --system skyview >/dev/null 2>&1 || true - fi - - # Remove data directories - rm -rf /var/lib/skyview - rm -rf /var/log/skyview - - # Remove config directory if empty - rmdir /etc/skyview 2>/dev/null || true - - echo "SkyView has been completely removed." - ;; - - remove) - # Reload systemd after service file removal - systemctl daemon-reload - ;; -esac - -exit 0 \ No newline at end of file diff --git a/debian/DEBIAN/prerm b/debian/DEBIAN/prerm deleted file mode 100755 index 68bdde7..0000000 --- a/debian/DEBIAN/prerm +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -set -e - -case "$1" in - remove|upgrade|deconfigure) - # Stop and disable the service - if systemctl is-active --quiet skyview.service; then - systemctl stop skyview.service - fi - - if systemctl is-enabled --quiet skyview.service; then - systemctl disable skyview.service - fi - ;; -esac - -exit 0 \ No newline at end of file diff --git a/debian/lib/systemd/system/skyview.service b/debian/lib/systemd/system/skyview.service deleted file mode 100644 index 7ed7b57..0000000 --- a/debian/lib/systemd/system/skyview.service +++ /dev/null @@ -1,47 +0,0 @@ -[Unit] -Description=SkyView Multi-Source ADS-B Aircraft Tracker -Documentation=https://github.com/skyview/skyview -After=network.target -Wants=network.target - -[Service] -Type=simple -User=skyview -Group=skyview -ExecStart=/usr/bin/skyview -config /etc/skyview/config.json -WorkingDirectory=/var/lib/skyview -StandardOutput=journal -StandardError=journal -SyslogIdentifier=skyview -Restart=always -RestartSec=5 - -# Security settings -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true -PrivateDevices=true -ProtectHostname=true -ProtectClock=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectControlGroups=true -RestrictRealtime=true -RestrictSUIDSGID=true -RemoveIPC=true -RestrictNamespaces=true - -# Allow network access -PrivateNetwork=false - -# Allow writing to log directory -ReadWritePaths=/var/log/skyview - -# Capabilities -CapabilityBoundingSet=CAP_NET_BIND_SERVICE -AmbientCapabilities=CAP_NET_BIND_SERVICE - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/debian/usr/share/man/man1/beast-dump.1 b/debian/usr/share/man/man1/beast-dump.1 deleted file mode 100644 index 2465981..0000000 --- a/debian/usr/share/man/man1/beast-dump.1 +++ /dev/null @@ -1,95 +0,0 @@ -.TH BEAST-DUMP 1 "2025-08-24" "SkyView 0.0.2" "User Commands" -.SH NAME -beast-dump \- Utility for analyzing raw ADS-B data in Beast binary format -.SH SYNOPSIS -.B beast-dump -[\fIOPTIONS\fR] [\fIFILE\fR] -.SH DESCRIPTION -beast-dump is a command-line utility for analyzing and decoding ADS-B -(Automatic Dependent Surveillance-Broadcast) data stored in Beast binary -format. It can read from files or connect to Beast format TCP streams -to decode and display aircraft messages. -.PP -The Beast format is a compact binary representation of Mode S/ADS-B -messages commonly used by dump1090 and similar software-defined radio -applications for aircraft tracking. -.SH OPTIONS -.TP -.B \-host \fIstring\fR -Connect to TCP host instead of reading from file -.TP -.B \-port \fIint\fR -TCP port to connect to (default 30005) -.TP -.B \-format \fIstring\fR -Output format: text, json, or csv (default "text") -.TP -.B \-filter \fIstring\fR -Filter by ICAO hex code (e.g., "A1B2C3") -.TP -.B \-types \fIstring\fR -Message types to display (comma-separated) -.TP -.B \-count \fIint\fR -Maximum number of messages to process -.TP -.B \-stats -Show statistics summary -.TP -.B \-verbose -Enable verbose output -.TP -.B \-h, \-help -Show help message and exit -.SH EXAMPLES -.TP -Analyze Beast format file: -.B beast-dump data.bin -.TP -Connect to live Beast stream: -.B beast-dump \-host localhost \-port 30005 -.TP -Export to JSON format with statistics: -.B beast-dump \-format json \-stats data.bin -.TP -Filter messages for specific aircraft: -.B beast-dump \-filter A1B2C3 \-verbose data.bin -.TP -Process only first 1000 messages as CSV: -.B beast-dump \-format csv \-count 1000 data.bin -.SH OUTPUT FORMAT -The default text output shows decoded message fields: -.PP -.nf -ICAO: A1B2C3 Type: 17 Time: 12:34:56.789 - Position: 51.4700, -0.4600 - Altitude: 35000 ft - Speed: 450 kt - Track: 090° -.fi -.PP -JSON output provides structured data suitable for further processing. -CSV output includes headers and is suitable for spreadsheet import. -.SH MESSAGE TYPES -Common ADS-B message types: -.IP \(bu 2 -Type 4/20: Altitude and identification -.IP \(bu 2 -Type 5/21: Surface position -.IP \(bu 2 -Type 9/18/22: Airborne position (baro altitude) -.IP \(bu 2 -Type 10/18/22: Airborne position (GNSS altitude) -.IP \(bu 2 -Type 17: Extended squitter ADS-B -.IP \(bu 2 -Type 19: Military extended squitter -.SH FILES -Beast format files typically use .bin or .beast extensions. -.SH SEE ALSO -.BR skyview (1), -.BR dump1090 (1) -.SH BUGS -Report bugs at: https://kode.naiv.no/olemd/skyview/issues -.SH AUTHOR -Ole-Morten Duesund \ No newline at end of file diff --git a/debian/usr/share/man/man1/skyview.1 b/debian/usr/share/man/man1/skyview.1 deleted file mode 100644 index 2c408c6..0000000 --- a/debian/usr/share/man/man1/skyview.1 +++ /dev/null @@ -1,88 +0,0 @@ -.TH SKYVIEW 1 "2025-08-24" "SkyView 0.0.2" "User Commands" -.SH NAME -skyview \- Multi-source ADS-B aircraft tracker with Beast format support -.SH SYNOPSIS -.B skyview -[\fIOPTIONS\fR] -.SH DESCRIPTION -SkyView is a standalone application that connects to multiple dump1090 Beast -format TCP streams and provides a modern web frontend for aircraft tracking. -It features real-time aircraft tracking, signal strength analysis, coverage -mapping, and 3D radar visualization. -.PP -The application serves a web interface on port 8080 by default and connects -to one or more Beast format data sources (typically dump1090 instances) to -aggregate aircraft data from multiple receivers. -.SH OPTIONS -.TP -.B \-config \fIstring\fR -Path to configuration file (default "config.json") -.TP -.B \-port \fIint\fR -HTTP server port (default 8080) -.TP -.B \-debug -Enable debug logging -.TP -.B \-version -Show version information and exit -.TP -.B \-h, \-help -Show help message and exit -.SH FILES -.TP -.I /etc/skyview/config.json -System-wide configuration file -.TP -.I ~/.config/skyview/config.json -Per-user configuration file -.SH EXAMPLES -.TP -Start with default configuration: -.B skyview -.TP -Start with custom config file: -.B skyview \-config /path/to/config.json -.TP -Start on port 9090 with debug logging: -.B skyview \-port 9090 \-debug -.SH CONFIGURATION -The configuration file uses JSON format with the following structure: -.PP -.nf -{ - "sources": [ - { - "id": "source1", - "name": "Local Receiver", - "host": "localhost", - "port": 30005, - "latitude": 51.4700, - "longitude": -0.4600 - } - ], - "web": { - "port": 8080, - "assets_path": "/usr/share/skyview/assets" - } -} -.fi -.SH WEB INTERFACE -The web interface provides: -.IP \(bu 2 -Interactive map view with aircraft markers -.IP \(bu 2 -Aircraft data table with filtering and sorting -.IP \(bu 2 -Real-time statistics and charts -.IP \(bu 2 -Coverage heatmaps and range circles -.IP \(bu 2 -3D radar visualization -.SH SEE ALSO -.BR beast-dump (1), -.BR dump1090 (1) -.SH BUGS -Report bugs at: https://kode.naiv.no/olemd/skyview/issues -.SH AUTHOR -Ole-Morten Duesund \ No newline at end of file diff --git a/docs/ADS-B Decoding Guide.pdf b/docs/ADS-B Decoding Guide.pdf deleted file mode 100644 index 87d319a..0000000 Binary files a/docs/ADS-B Decoding Guide.pdf and /dev/null differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index a018249..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,290 +0,0 @@ -# SkyView Architecture Documentation - -## Overview - -SkyView is a high-performance, multi-source ADS-B aircraft tracking system built in Go with a modern JavaScript frontend. It connects to multiple dump1090 Beast format receivers, performs intelligent data fusion, and provides low-latency aircraft tracking through a responsive web interface. - -## System Architecture - -``` -┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ dump1090 │ │ dump1090 │ │ dump1090 │ -│ Receiver 1 │ │ Receiver 2 │ │ Receiver N │ -│ Port 30005 │ │ Port 30005 │ │ Port 30005 │ -└─────────┬───────┘ └──────┬───────┘ └─────────┬───────┘ - │ │ │ - │ Beast Binary │ Beast Binary │ Beast Binary - │ TCP Stream │ TCP Stream │ TCP Stream - │ │ │ - └───────────────────┼──────────────────────┘ - │ - ┌─────────▼──────────┐ - │ SkyView Server │ - │ │ - │ ┌────────────────┐ │ - │ │ Beast Client │ │ ── Multi-source TCP clients - │ │ Manager │ │ - │ └────────────────┘ │ - │ ┌────────────────┐ │ - │ │ Mode S/ADS-B │ │ ── Message parsing & decoding - │ │ Decoder │ │ - │ └────────────────┘ │ - │ ┌────────────────┐ │ - │ │ Data Merger │ │ ── Intelligent data fusion - │ │ & ICAO DB │ │ - │ └────────────────┘ │ - │ ┌────────────────┐ │ - │ │ HTTP/WebSocket │ │ ── Low-latency web interface - │ │ Server │ │ - │ └────────────────┘ │ - └─────────┬──────────┘ - │ - ┌─────────▼──────────┐ - │ Web Interface │ - │ │ - │ • Interactive Maps │ - │ • Low-latency Updates│ - │ • Aircraft Details │ - │ • Coverage Analysis│ - │ • 3D Visualization │ - └────────────────────┘ -``` - -## Core Components - -### 1. Beast Format Clients (`internal/client/`) - -**Purpose**: Manages TCP connections to dump1090 receivers - -**Key Features**: -- Concurrent connection handling for multiple sources -- Automatic reconnection with exponential backoff -- Beast binary format parsing -- Per-source connection monitoring and statistics - -**Files**: -- `beast.go`: Main client implementation - -### 2. Mode S/ADS-B Decoder (`internal/modes/`) - -**Purpose**: Decodes raw Mode S and ADS-B messages into structured aircraft data - -**Key Features**: -- CPR (Compact Position Reporting) decoding with zone ambiguity resolution -- ADS-B message type parsing (position, velocity, identification) -- Aircraft category and type classification -- Signal quality assessment - -**Files**: -- `decoder.go`: Core decoding logic - -### 3. Data Merger (`internal/merger/`) - -**Purpose**: Fuses aircraft data from multiple sources using intelligent conflict resolution - -**Key Features**: -- Signal strength-based source selection -- High-performance data fusion and conflict resolution -- Aircraft state management and lifecycle tracking -- Historical data collection (position, altitude, speed, signal trails) -- Automatic stale aircraft cleanup - -**Files**: -- `merger.go`: Multi-source data fusion engine - -### 4. ICAO Country Database (`internal/icao/`) - -**Purpose**: Provides comprehensive ICAO address to country mapping - -**Key Features**: -- Embedded SQLite database with 70+ allocations covering 40+ countries -- Based on official ICAO Document 8585 -- Fast range-based lookups using database indexing -- Country names, ISO codes, and flag emojis - -**Files**: -- `database.go`: SQLite database interface -- `icao.db`: Embedded SQLite database with ICAO allocations - -### 5. HTTP/WebSocket Server (`internal/server/`) - -**Purpose**: Serves web interface and provides low-latency data streaming - -**Key Features**: -- RESTful API for aircraft and system data -- WebSocket connections for low-latency updates -- Static asset serving with embedded resources -- Coverage analysis and signal heatmaps - -**Files**: -- `server.go`: HTTP server and WebSocket handler - -### 6. Web Frontend (`assets/static/`) - -**Purpose**: Interactive web interface for aircraft tracking and visualization - -**Key Technologies**: -- **Leaflet.js**: Interactive maps and aircraft markers -- **Three.js**: 3D radar visualization -- **Chart.js**: Live statistics and charts -- **WebSockets**: Live data streaming -- **Responsive CSS**: Mobile-optimized interface - -**Files**: -- `index.html`: Main web interface -- `js/app.js`: Main application orchestrator -- `js/modules/`: Modular JavaScript components - - `aircraft-manager.js`: Aircraft marker and trail management - - `map-manager.js`: Map controls and overlays - - `ui-manager.js`: User interface state management - - `websocket.js`: Low-latency data connections -- `css/style.css`: Responsive styling and themes -- `icons/`: SVG aircraft type icons - -## Data Flow - -### 1. Data Ingestion -1. **Beast Clients** connect to dump1090 receivers via TCP -2. **Beast Parser** processes binary message stream -3. **Mode S Decoder** converts raw messages to structured aircraft data -4. **Data Merger** receives aircraft updates with source attribution - -### 2. Data Fusion -1. **Signal Analysis**: Compare signal strength across sources -2. **Conflict Resolution**: Select best data based on signal quality and recency -3. **State Management**: Update aircraft position, velocity, and metadata -4. **History Tracking**: Maintain trails for visualization - -### 3. Country Lookup -1. **ICAO Extraction**: Extract 24-bit ICAO address from aircraft data -2. **Database Query**: Lookup country information in embedded SQLite database -3. **Data Enrichment**: Add country, country code, and flag to aircraft state - -### 4. Data Distribution -1. **REST API**: Provide aircraft data via HTTP endpoints -2. **WebSocket Streaming**: Push low-latency updates to connected clients -3. **Frontend Processing**: Update maps, tables, and visualizations -4. **User Interface**: Display aircraft with country flags and details - -## Configuration System - -### Configuration Sources (Priority Order) -1. Command-line flags (highest priority) -2. Configuration file (JSON) -3. Default values (lowest priority) - -### Configuration Structure -```json -{ - "server": { - "host": "", // Bind address (empty = all interfaces) - "port": 8080 // HTTP server port - }, - "sources": [ - { - "id": "unique-id", // Source identifier - "name": "Display Name", // Human-readable name - "host": "hostname", // Receiver hostname/IP - "port": 30005, // Beast format port - "latitude": 51.4700, // Receiver location - "longitude": -0.4600, - "altitude": 50.0, // Meters above sea level - "enabled": true // Source enable/disable - } - ], - "settings": { - "history_limit": 500, // Max trail points per aircraft - "stale_timeout": 60, // Seconds before aircraft removed - "update_rate": 1 // WebSocket update frequency - }, - "origin": { - "latitude": 51.4700, // Map center point - "longitude": -0.4600, - "name": "Origin Name" - } -} -``` - -## Performance Characteristics - -### Concurrency Model -- **Goroutine per Source**: Each Beast client runs in separate goroutine -- **Mutex-Protected Merger**: Thread-safe aircraft state management -- **WebSocket Broadcasting**: Concurrent client update distribution -- **Non-blocking I/O**: Asynchronous network operations - -### Memory Management -- **Bounded History**: Configurable limits on historical data storage -- **Automatic Cleanup**: Stale aircraft removal to prevent memory leaks -- **Efficient Data Structures**: Maps for O(1) aircraft lookups -- **Embedded Assets**: Static files bundled in binary - -### Scalability -- **Multi-source Support**: Tested with 10+ concurrent receivers -- **High Message Throughput**: Handles 1000+ messages/second per source -- **Low-latency Updates**: Sub-second latency for aircraft updates -- **Responsive Web UI**: Optimized for 100+ concurrent aircraft - -## Security Considerations - -### Network Security -- **No Authentication Required**: Designed for trusted network environments -- **Local Network Operation**: Intended for private receiver networks -- **WebSocket Origin Checking**: Basic CORS protection - -### System Security -- **Unprivileged Execution**: Runs as non-root user in production -- **Filesystem Isolation**: Minimal file system access required -- **Network Isolation**: Only requires outbound TCP to receivers -- **Systemd Hardening**: Security features enabled in service file - -### Data Privacy -- **Public ADS-B Data**: Only processes publicly broadcast aircraft data -- **No Personal Information**: Aircraft tracking only, no passenger data -- **Local Processing**: No data transmitted to external services -- **Historical Limits**: Configurable data retention periods - -## External Resources - -### Official Standards -- **ICAO Document 8585**: Designators for Aircraft Operating Agencies -- **RTCA DO-260B**: ADS-B Message Formats and Protocols -- **ITU-R M.1371-5**: Technical characteristics for universal ADS-B - -### Technology Dependencies -- **Go Language**: https://golang.org/ -- **Leaflet.js**: https://leafletjs.com/ - Interactive maps -- **Three.js**: https://threejs.org/ - 3D visualization -- **Chart.js**: https://www.chartjs.org/ - Statistics charts -- **SQLite**: https://www.sqlite.org/ - ICAO country database -- **WebSocket Protocol**: RFC 6455 - -### ADS-B Ecosystem -- **dump1090**: https://github.com/antirez/dump1090 - SDR ADS-B decoder -- **Beast Binary Format**: Mode S data interchange format -- **FlightAware**: ADS-B network and data provider -- **OpenSky Network**: Research-oriented ADS-B network - -## Development Guidelines - -### Code Organization -- **Package per Component**: Clear separation of concerns -- **Interface Abstractions**: Testable and mockable components -- **Error Handling**: Comprehensive error reporting and recovery -- **Documentation**: Extensive code comments and examples - -### Testing Strategy -- **Unit Tests**: Component-level testing with mocks -- **Integration Tests**: End-to-end data flow validation -- **Performance Tests**: Load testing with simulated data -- **Manual Testing**: Real-world receiver validation - -### Deployment Options -- **Standalone Binary**: Single executable with embedded assets -- **Debian Package**: Systemd service with configuration -- **Docker Container**: Containerized deployment option -- **Development Mode**: Hot-reload for frontend development - ---- - -**SkyView Architecture** - Designed for reliability, performance, and extensibility in multi-source ADS-B tracking applications. \ No newline at end of file diff --git a/internal/beast/parser.go b/internal/beast/parser.go deleted file mode 100644 index ec5afed..0000000 --- a/internal/beast/parser.go +++ /dev/null @@ -1,322 +0,0 @@ -// Package beast provides Beast binary format parsing for ADS-B message streams. -// -// The Beast format is a binary protocol developed by FlightAware and used by -// dump1090, readsb, and other ADS-B software to stream real-time aircraft data -// over TCP connections (typically port 30005). -// -// Beast Format Structure: -// - Each message starts with escape byte 0x1A -// - Message type byte (0x31=Mode A/C, 0x32=Mode S Short, 0x33=Mode S Long) -// - 48-bit timestamp (12MHz clock ticks) -// - Signal level byte (RSSI) -// - Message payload (2, 7, or 14 bytes depending on type) -// - Escape sequences: 0x1A 0x1A represents literal 0x1A in data -// -// This package handles: -// - Binary message parsing and validation -// - Timestamp and signal strength extraction -// - Escape sequence processing -// - ICAO address and message type extraction -// - Continuous stream processing with error recovery -// -// The parser is designed to handle connection interruptions gracefully and -// can recover from malformed messages in the stream. -package beast - -import ( - "bufio" - "encoding/binary" - "errors" - "fmt" - "io" - "time" -) - -// Beast format message type constants. -// These define the different types of messages in the Beast binary protocol. -const ( - BeastModeAC = 0x31 // '1' - Mode A/C squitter (2 bytes payload) - BeastModeS = 0x32 // '2' - Mode S Short squitter (7 bytes payload) - BeastModeSLong = 0x33 // '3' - Mode S Extended squitter (14 bytes payload) - BeastStatusMsg = 0x34 // '4' - Status message (variable length) - BeastEscape = 0x1A // Escape character (0x1A 0x1A = literal 0x1A) -) - -// Message represents a parsed Beast format message with metadata. -// -// Contains both the raw Beast protocol fields and additional processing metadata: -// - Original Beast format fields (type, timestamp, signal, data) -// - Processing timestamp for age calculations -// - Source identification for multi-receiver setups -type Message struct { - Type byte // Beast message type (0x31, 0x32, 0x33, 0x34) - Timestamp uint64 // 48-bit timestamp in 12MHz ticks from receiver - Signal uint8 // Signal level (RSSI) - 255 = 0 dBFS, 0 = minimum - Data []byte // Mode S message payload (2, 7, or 14 bytes) - ReceivedAt time.Time // Local processing timestamp - SourceID string // Identifier for the source receiver -} - -// Parser handles Beast binary format parsing from a stream. -// -// The parser maintains stream state and can recover from protocol errors -// by searching for the next valid message boundary. It uses buffered I/O -// for efficient byte-level parsing of the binary protocol. -type Parser struct { - reader *bufio.Reader // Buffered reader for efficient byte parsing - sourceID string // Source identifier for message tagging -} - -// NewParser creates a new Beast format parser for a data stream. -// -// The parser wraps the provided reader with a buffered reader for efficient -// parsing of the binary protocol. Each parsed message will be tagged with -// the provided sourceID for multi-source identification. -// -// Parameters: -// - r: Input stream containing Beast format data -// - sourceID: Identifier for this data source -// -// Returns a configured parser ready for message parsing. -func NewParser(r io.Reader, sourceID string) *Parser { - return &Parser{ - reader: bufio.NewReader(r), - sourceID: sourceID, - } -} - -// ReadMessage reads and parses a single Beast message from the stream. -// -// The parsing process: -// 1. Search for the escape character (0x1A) that marks message start -// 2. Read and validate the message type byte -// 3. Read the 48-bit timestamp (big-endian, padded to 64-bit) -// 4. Read the signal level byte -// 5. Read the message payload (length depends on message type) -// 6. Process escape sequences in the payload data -// -// The parser can recover from protocol errors by continuing to search for -// the next valid message boundary. Status messages are currently skipped -// as they contain variable-length data not needed for aircraft tracking. -// -// Returns the parsed message or an error if the stream is closed or corrupted. -func (p *Parser) ReadMessage() (*Message, error) { - // Look for escape character - for { - b, err := p.reader.ReadByte() - if err != nil { - return nil, err - } - if b == BeastEscape { - break - } - } - - // Read message type - msgType, err := p.reader.ReadByte() - if err != nil { - return nil, err - } - - // Validate message type - var dataLen int - switch msgType { - case BeastModeAC: - dataLen = 2 - case BeastModeS: - dataLen = 7 - case BeastModeSLong: - dataLen = 14 - 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: - // 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) - timestampBytes := make([]byte, 8) - if _, err := io.ReadFull(p.reader, timestampBytes[2:]); err != nil { - return nil, err - } - timestamp := binary.BigEndian.Uint64(timestampBytes) - - // Read signal level (1 byte) - signal, err := p.reader.ReadByte() - if err != nil { - return nil, err - } - - // Read Mode S data - data := make([]byte, dataLen) - if _, err := io.ReadFull(p.reader, data); err != nil { - return nil, err - } - - // Unescape data if needed - data = p.unescapeData(data) - - return &Message{ - Type: msgType, - Timestamp: timestamp, - Signal: signal, - Data: data, - ReceivedAt: time.Now(), - SourceID: p.sourceID, - }, nil -} - -// unescapeData removes escape sequences from Beast format payload data. -// -// Beast format uses escape sequences to embed the escape character (0x1A) -// in message payloads: -// - 0x1A 0x1A in the stream represents a literal 0x1A byte in the data -// - Single 0x1A bytes are message boundaries, not data -// -// This method processes the payload after parsing to restore the original -// Mode S message bytes with any embedded escape characters. -// -// Parameters: -// - data: Raw payload bytes that may contain escape sequences -// -// Returns the unescaped data with literal 0x1A bytes restored. -func (p *Parser) unescapeData(data []byte) []byte { - result := make([]byte, 0, len(data)) - i := 0 - for i < len(data) { - if i < len(data)-1 && data[i] == BeastEscape && data[i+1] == BeastEscape { - result = append(result, BeastEscape) - i += 2 - } else { - result = append(result, data[i]) - i++ - } - } - return result -} - -// ParseStream continuously reads messages from the stream until an error occurs. -// -// This method runs in a loop, parsing messages and sending them to the provided -// channel. It handles various error conditions gracefully: -// - EOF and closed pipe errors terminate normally (expected on disconnect) -// - Other errors are reported via the error channel with source identification -// - Protocol errors within individual messages are recovered from automatically -// -// The method blocks until the stream closes or an unrecoverable error occurs. -// It's designed to run in a dedicated goroutine for continuous processing. -// -// Parameters: -// - msgChan: Channel for sending successfully parsed messages -// - errChan: Channel for reporting parsing errors -func (p *Parser) ParseStream(msgChan chan<- *Message, errChan chan<- error) { - for { - msg, err := p.ReadMessage() - if err != nil { - if err != io.EOF && !errors.Is(err, io.ErrClosedPipe) { - errChan <- fmt.Errorf("parser error from %s: %w", p.sourceID, err) - } - return - } - msgChan <- msg - } -} - -// GetSignalStrength converts the Beast signal level byte to dBFS (decibels full scale). -// -// The Beast format encodes signal strength as: -// - 255 = 0 dBFS (maximum signal, clipping) -// - Lower values = weaker signals -// - 0 = minimum detectable signal (~-50 dBFS) -// -// The conversion provides a logarithmic scale suitable for signal quality -// comparison and coverage analysis. Values typically range from -50 to 0 dBFS -// in normal operation. -// -// Returns signal strength in dBFS (negative values, closer to 0 = stronger). -func (msg *Message) GetSignalStrength() float64 { - // Beast format: signal level is in units where 255 = 0 dBFS - // Typical range is -50 to 0 dBFS - if msg.Signal == 0 { - return -50.0 // Minimum detectable signal - } - return float64(msg.Signal) * (-50.0 / 255.0) -} - -// GetICAO24 extracts the ICAO 24-bit aircraft address from Mode S messages. -// -// The ICAO address is a unique 24-bit identifier assigned to each aircraft. -// In Mode S messages, it's located in bytes 1-3 of the message payload: -// - Byte 1: Most significant 8 bits -// - Byte 2: Middle 8 bits -// - Byte 3: Least significant 8 bits -// -// Mode A/C messages don't contain ICAO addresses and will return an error. -// The ICAO address is used as the primary key for aircraft tracking. -// -// Returns the 24-bit ICAO address as a uint32, or an error for invalid messages. -func (msg *Message) GetICAO24() (uint32, error) { - if msg.Type == BeastModeAC { - return 0, errors.New("mode A/C messages don't contain ICAO address") - } - - if len(msg.Data) < 4 { - return 0, errors.New("insufficient data for ICAO address") - } - - // ICAO address is in bytes 1-3 of Mode S messages - icao := uint32(msg.Data[1])<<16 | uint32(msg.Data[2])<<8 | uint32(msg.Data[3]) - return icao, nil -} - -// GetDownlinkFormat extracts the Downlink Format (DF) from Mode S messages. -// -// The DF field occupies the first 5 bits of every Mode S message and indicates -// the message type and structure: -// - DF 0: Short air-air surveillance -// - DF 4/5: Surveillance altitude/identity reply -// - DF 11: All-call reply -// - DF 17: Extended squitter (ADS-B) -// - DF 18: Extended squitter/non-transponder -// - DF 19: Military extended squitter -// - Others: Various surveillance and communication types -// -// Returns the 5-bit DF field value, or 0 if no data is available. -func (msg *Message) GetDownlinkFormat() uint8 { - if len(msg.Data) == 0 { - return 0 - } - return (msg.Data[0] >> 3) & 0x1F -} - -// GetTypeCode extracts the Type Code (TC) from ADS-B extended squitter messages. -// -// The Type Code is a 5-bit field that indicates the specific type of ADS-B message: -// - TC 1-4: Aircraft identification and category -// - TC 5-8: Surface position messages -// - TC 9-18: Airborne position messages (different altitude sources) -// - TC 19: Airborne velocity messages -// - TC 20-22: Reserved for future use -// - Others: Various operational and status messages -// -// Only extended squitter messages (DF 17/18) contain type codes. Other -// message types will return an error. -// -// Returns the 5-bit type code, or an error for non-extended squitter messages. -func (msg *Message) GetTypeCode() (uint8, error) { - df := msg.GetDownlinkFormat() - if df != 17 && df != 18 { // Extended squitter - return 0, errors.New("not an extended squitter message") - } - - if len(msg.Data) < 5 { - return 0, errors.New("insufficient data for type code") - } - - return (msg.Data[4] >> 3) & 0x1F, nil -} diff --git a/internal/client/beast.go b/internal/client/beast.go deleted file mode 100644 index 6810874..0000000 --- a/internal/client/beast.go +++ /dev/null @@ -1,411 +0,0 @@ -// Package client provides Beast format TCP client implementations for connecting to ADS-B receivers. -// -// This package handles the network connectivity and data streaming from dump1090 or similar -// Beast format sources. It provides: -// - Single-source Beast TCP client with automatic reconnection -// - Multi-source client manager for handling multiple receivers -// - Exponential backoff for connection failures -// - Message parsing and Mode S decoding integration -// - Automatic stale aircraft cleanup -// -// The Beast format is a binary protocol commonly used by dump1090 and other ADS-B -// software to stream real-time aircraft data over TCP port 30005. This package -// abstracts the connection management and integrates with the merger for -// multi-source data fusion. -package client - -import ( - "context" - "fmt" - "net" - "sync" - "time" - - "skyview/internal/beast" - "skyview/internal/merger" - "skyview/internal/modes" -) - -// BeastClient handles connection to a single dump1090 Beast format TCP stream. -// -// The client provides robust connectivity with: -// - Automatic reconnection with exponential backoff -// - Concurrent message reading and processing -// - Integration with Mode S decoder and data merger -// - Source status tracking and statistics -// - Graceful shutdown handling -// -// Each client maintains a persistent connection to one Beast source and -// continuously processes incoming messages until stopped or the source -// becomes unavailable. -type BeastClient struct { - source *merger.Source // Source configuration and status - merger *merger.Merger // Data merger for multi-source fusion - decoder *modes.Decoder // Mode S/ADS-B message decoder - conn net.Conn // TCP connection to Beast source - parser *beast.Parser // Beast format message parser - msgChan chan *beast.Message // Buffered channel for parsed messages - errChan chan error // Error reporting channel - stopChan chan struct{} // Shutdown signal channel - wg sync.WaitGroup // Wait group for goroutine coordination - - // Reconnection parameters - reconnectDelay time.Duration // Initial reconnect delay - maxReconnect time.Duration // Maximum reconnect delay (for backoff cap) -} - -// NewBeastClient creates a new Beast format TCP client for a specific data source. -// -// The client is configured with: -// - Buffered message channel (1000 messages) to handle burst traffic -// - Error channel for connection and parsing issues -// - Initial reconnect delay of 5 seconds -// - Maximum reconnect delay of 60 seconds (exponential backoff cap) -// - Fresh Mode S decoder instance -// -// Parameters: -// - source: Source configuration including host, port, and metadata -// - merger: Data merger instance for aircraft state management -// -// Returns a configured but not yet started BeastClient. -func NewBeastClient(source *merger.Source, merger *merger.Merger) *BeastClient { - return &BeastClient{ - source: source, - merger: merger, - decoder: modes.NewDecoder(source.Latitude, source.Longitude), - msgChan: make(chan *beast.Message, 5000), - errChan: make(chan error, 10), - stopChan: make(chan struct{}), - reconnectDelay: 5 * time.Second, - maxReconnect: 60 * time.Second, - } -} - -// Start begins the client connection and message processing in the background. -// -// The client will: -// - Attempt to connect to the configured Beast source -// - Handle connection failures with exponential backoff -// - Start message reading and processing goroutines -// - Continuously reconnect if the connection is lost -// -// The method returns immediately; the client runs in background goroutines -// until Stop() is called or the context is cancelled. -// -// Parameters: -// - ctx: Context for cancellation and timeout control -func (c *BeastClient) Start(ctx context.Context) { - c.wg.Add(1) - go c.run(ctx) -} - -// Stop gracefully shuts down the client and all associated goroutines. -// -// The shutdown process: -// 1. Signals all goroutines to stop via stopChan -// 2. Closes the TCP connection if active -// 3. Waits for all goroutines to complete -// -// This method blocks until the shutdown is complete. -func (c *BeastClient) Stop() { - close(c.stopChan) - if c.conn != nil { - c.conn.Close() - } - c.wg.Wait() -} - -// run implements the main client connection and reconnection loop. -// -// This method handles the complete client lifecycle: -// 1. Connection establishment with timeout -// 2. Exponential backoff on connection failures -// 3. Message parsing and processing goroutine management -// 4. Connection monitoring and failure detection -// 5. Automatic reconnection on disconnection -// -// The exponential backoff starts at reconnectDelay (5s) and doubles on each -// failure up to maxReconnect (60s), then resets on successful connection. -// -// Source status is updated to reflect connection state for monitoring. -func (c *BeastClient) run(ctx context.Context) { - defer c.wg.Done() - - reconnectDelay := c.reconnectDelay - - for { - select { - case <-ctx.Done(): - return - case <-c.stopChan: - return - default: - } - - // Connect to Beast TCP stream - addr := fmt.Sprintf("%s:%d", c.source.Host, c.source.Port) - fmt.Printf("Connecting to Beast stream at %s (%s)...\n", addr, c.source.Name) - - conn, err := net.DialTimeout("tcp", addr, 30*time.Second) - if err != nil { - fmt.Printf("Failed to connect to %s: %v\n", c.source.Name, err) - c.source.Active = false - - // Exponential backoff - time.Sleep(reconnectDelay) - if reconnectDelay < c.maxReconnect { - reconnectDelay *= 2 - } - continue - } - - c.conn = conn - c.source.Active = true - reconnectDelay = c.reconnectDelay // Reset backoff - - fmt.Printf("Connected to %s at %s\n", c.source.Name, addr) - - // Create parser for this connection - c.parser = beast.NewParser(conn, c.source.ID) - - // Start processing messages - c.wg.Add(2) - go c.readMessages() - go c.processMessages() - - // Wait for disconnect - select { - case <-ctx.Done(): - c.conn.Close() - return - case <-c.stopChan: - c.conn.Close() - return - case err := <-c.errChan: - fmt.Printf("Error from %s: %v\n", c.source.Name, err) - c.conn.Close() - c.source.Active = false - } - - // Wait for goroutines to finish - time.Sleep(1 * time.Second) - } -} - -// readMessages runs in a dedicated goroutine to read Beast format messages. -// -// This method: -// - Continuously reads from the TCP connection -// - Parses Beast format binary data into Message structs -// - Queues parsed messages for processing -// - Reports parsing errors to the error channel -// -// The method blocks on the parser's ParseStream call and exits when -// the connection is closed or an unrecoverable error occurs. -func (c *BeastClient) readMessages() { - defer c.wg.Done() - c.parser.ParseStream(c.msgChan, c.errChan) -} - -// processMessages runs in a dedicated goroutine to decode and merge aircraft data. -// -// For each received Beast message, this method: -// 1. Decodes the Mode S/ADS-B message payload -// 2. Extracts aircraft information (position, altitude, speed, etc.) -// 3. Updates the data merger with new aircraft state -// 4. Updates source statistics (message count) -// -// Invalid or unparseable messages are silently discarded to maintain -// system stability. The merger handles data fusion from multiple sources -// and conflict resolution based on signal strength. -func (c *BeastClient) processMessages() { - defer c.wg.Done() - - for { - select { - case <-c.stopChan: - return - case msg := <-c.msgChan: - if msg == nil { - return - } - - // Decode Mode S message - aircraft, err := c.decoder.Decode(msg.Data) - if err != nil { - continue // Skip invalid messages - } - - // Update merger with new data - c.merger.UpdateAircraft( - c.source.ID, - aircraft, - msg.GetSignalStrength(), - msg.ReceivedAt, - ) - - // Update source statistics - c.source.Messages++ - } - } -} - -// MultiSourceClient manages multiple Beast TCP clients for multi-receiver setups. -// -// This client coordinator: -// - Manages connections to multiple Beast format sources simultaneously -// - Provides unified control for starting and stopping all clients -// - Runs periodic cleanup tasks for stale aircraft data -// - Aggregates statistics from all managed clients -// - Handles dynamic source addition and management -// -// All clients share the same data merger, enabling automatic data fusion -// and conflict resolution across multiple receivers. -type MultiSourceClient struct { - clients []*BeastClient // Managed Beast clients - merger *merger.Merger // Shared data merger for all sources - mu sync.RWMutex // Protects clients slice -} - -// NewMultiSourceClient creates a client manager for multiple Beast format sources. -// -// The multi-source client enables connecting to multiple dump1090 instances -// or other Beast format sources simultaneously. All sources feed into the -// same data merger, which handles automatic data fusion and conflict resolution. -// -// This is essential for: -// - Improved coverage from multiple receivers -// - Redundancy in case of individual receiver failures -// - Data quality improvement through signal strength comparison -// -// Parameters: -// - merger: Shared data merger instance for all sources -// -// Returns a configured multi-source client ready for source addition. -func NewMultiSourceClient(merger *merger.Merger) *MultiSourceClient { - return &MultiSourceClient{ - clients: make([]*BeastClient, 0), - merger: merger, - } -} - -// AddSource registers and configures a new Beast format data source. -// -// This method: -// 1. Registers the source with the data merger -// 2. Creates a new BeastClient for the source -// 3. Adds the client to the managed clients list -// -// The source is not automatically started; call Start() to begin connections. -// Sources can be added before or after starting the multi-source client. -// -// Parameters: -// - source: Source configuration including connection details and metadata -func (m *MultiSourceClient) AddSource(source *merger.Source) { - m.mu.Lock() - defer m.mu.Unlock() - - // Register source with merger - m.merger.AddSource(source) - - // Create and start client - client := NewBeastClient(source, m.merger) - m.clients = append(m.clients, client) -} - -// Start begins connections to all configured Beast sources. -// -// This method: -// - Starts all managed BeastClient instances in parallel -// - Begins the periodic cleanup routine for stale aircraft data -// - Uses the provided context for cancellation control -// -// Each client will independently attempt connections with their own -// reconnection logic. The method returns immediately; all clients -// operate in background goroutines. -// -// Parameters: -// - ctx: Context for cancellation and timeout control -func (m *MultiSourceClient) Start(ctx context.Context) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, client := range m.clients { - client.Start(ctx) - } - - // Start cleanup routine - go m.cleanupRoutine(ctx) -} - -// Stop gracefully shuts down all managed Beast clients. -// -// This method stops all clients in parallel and waits for their -// goroutines to complete. The shutdown is coordinated to ensure -// clean termination of all network connections and processing routines. -func (m *MultiSourceClient) Stop() { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, client := range m.clients { - client.Stop() - } -} - -// cleanupRoutine runs periodic maintenance tasks in a background goroutine. -// -// Currently performs: -// - Stale aircraft cleanup every 30 seconds -// - Removal of aircraft that haven't been updated recently -// -// The cleanup frequency is designed to balance memory usage with -// the typical aircraft update rates in ADS-B systems. Aircraft -// typically update their position every few seconds when in range. -// -// Parameters: -// - ctx: Context for cancellation when the client shuts down -func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - m.merger.CleanupStale() - } - } -} - -// GetStatistics returns comprehensive statistics from all managed clients. -// -// The statistics include: -// - All merger statistics (aircraft count, message rates, etc.) -// - Number of active client connections -// - Total number of configured clients -// - Per-source connection status and message counts -// -// This information is useful for monitoring system health, diagnosing -// connectivity issues, and understanding data quality across sources. -// -// Returns a map of statistics suitable for JSON serialization and web display. -func (m *MultiSourceClient) GetStatistics() map[string]interface{} { - m.mu.RLock() - defer m.mu.RUnlock() - - stats := m.merger.GetStatistics() - - // Add client-specific stats - activeClients := 0 - for _, client := range m.clients { - if client.source.Active { - activeClients++ - } - } - - stats["active_clients"] = activeClients - stats["total_clients"] = len(m.clients) - - return stats -} diff --git a/internal/client/dump1090.go b/internal/client/dump1090.go new file mode 100644 index 0000000..0e08352 --- /dev/null +++ b/internal/client/dump1090.go @@ -0,0 +1,267 @@ +package client + +import ( + "bufio" + "context" + "fmt" + "log" + "net" + "sync" + "time" + + "skyview/internal/config" + "skyview/internal/parser" +) + +type Dump1090Client struct { + config *config.Config + aircraftMap map[string]*parser.Aircraft + mutex sync.RWMutex + subscribers []chan parser.AircraftData + subMutex sync.RWMutex +} + +func NewDump1090Client(cfg *config.Config) *Dump1090Client { + return &Dump1090Client{ + config: cfg, + aircraftMap: make(map[string]*parser.Aircraft), + subscribers: make([]chan parser.AircraftData, 0), + } +} + +func (c *Dump1090Client) Start(ctx context.Context) error { + go c.startDataStream(ctx) + go c.startPeriodicBroadcast(ctx) + go c.startCleanup(ctx) + return nil +} + +func (c *Dump1090Client) startDataStream(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + if err := c.connectAndRead(ctx); err != nil { + log.Printf("Connection error: %v, retrying in 5s", err) + time.Sleep(5 * time.Second) + } + } + } +} + +func (c *Dump1090Client) connectAndRead(ctx context.Context) error { + address := fmt.Sprintf("%s:%d", c.config.Dump1090.Host, c.config.Dump1090.DataPort) + + conn, err := net.Dial("tcp", address) + if err != nil { + return fmt.Errorf("failed to connect to %s: %w", address, err) + } + defer conn.Close() + + log.Printf("Connected to dump1090 at %s", address) + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + select { + case <-ctx.Done(): + return nil + default: + line := scanner.Text() + c.processLine(line) + } + } + + return scanner.Err() +} + +func (c *Dump1090Client) processLine(line string) { + aircraft, err := parser.ParseSBS1Line(line) + if err != nil || aircraft == nil { + return + } + + c.mutex.Lock() + if existing, exists := c.aircraftMap[aircraft.Hex]; exists { + c.updateExistingAircraft(existing, aircraft) + } else { + c.aircraftMap[aircraft.Hex] = aircraft + } + c.mutex.Unlock() +} + +func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraft) { + existing.LastSeen = update.LastSeen + existing.Messages++ + + if update.Flight != "" { + existing.Flight = update.Flight + } + if update.Altitude != 0 { + existing.Altitude = update.Altitude + } + if update.GroundSpeed != 0 { + existing.GroundSpeed = update.GroundSpeed + } + if update.Track != 0 { + existing.Track = update.Track + } + if update.Latitude != 0 && update.Longitude != 0 { + existing.Latitude = update.Latitude + existing.Longitude = update.Longitude + + // Add to track history if position changed significantly + if c.shouldAddTrackPoint(existing, update) { + trackPoint := parser.TrackPoint{ + Timestamp: update.LastSeen, + Latitude: update.Latitude, + Longitude: update.Longitude, + Altitude: update.Altitude, + Speed: update.GroundSpeed, + Track: update.Track, + } + + existing.TrackHistory = append(existing.TrackHistory, trackPoint) + + // Keep only last 200 points (about 3-4 hours at 1 point/minute) + if len(existing.TrackHistory) > 200 { + existing.TrackHistory = existing.TrackHistory[1:] + } + } + } + if update.VertRate != 0 { + existing.VertRate = update.VertRate + } + if update.Squawk != "" { + existing.Squawk = update.Squawk + } + existing.OnGround = update.OnGround + + // Preserve country and registration + if update.Country != "" && update.Country != "Unknown" { + existing.Country = update.Country + } + if update.Registration != "" { + existing.Registration = update.Registration + } +} + +func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft) bool { + // Add track point if: + // 1. No history yet + if len(existing.TrackHistory) == 0 { + return true + } + + lastPoint := existing.TrackHistory[len(existing.TrackHistory)-1] + + // 2. At least 30 seconds since last point + if time.Since(lastPoint.Timestamp) < 30*time.Second { + return false + } + + // 3. Position changed by at least 0.001 degrees (~100m) + latDiff := existing.Latitude - lastPoint.Latitude + lonDiff := existing.Longitude - lastPoint.Longitude + distanceChange := latDiff*latDiff + lonDiff*lonDiff + + return distanceChange > 0.000001 // ~0.001 degrees squared +} + +func (c *Dump1090Client) GetAircraftData() parser.AircraftData { + c.mutex.RLock() + defer c.mutex.RUnlock() + + aircraftMap := make(map[string]parser.Aircraft) + totalMessages := 0 + + for hex, aircraft := range c.aircraftMap { + aircraftMap[hex] = *aircraft + totalMessages += aircraft.Messages + } + + return parser.AircraftData{ + Now: time.Now().Unix(), + Messages: totalMessages, + Aircraft: aircraftMap, + } +} + +func (c *Dump1090Client) Subscribe() <-chan parser.AircraftData { + c.subMutex.Lock() + defer c.subMutex.Unlock() + + ch := make(chan parser.AircraftData, 10) + c.subscribers = append(c.subscribers, ch) + return ch +} + +func (c *Dump1090Client) startPeriodicBroadcast(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + data := c.GetAircraftData() + c.broadcastToSubscribers(data) + } + } +} + +func (c *Dump1090Client) broadcastToSubscribers(data parser.AircraftData) { + c.subMutex.RLock() + defer c.subMutex.RUnlock() + + for i, ch := range c.subscribers { + select { + case ch <- data: + default: + close(ch) + c.subMutex.RUnlock() + c.subMutex.Lock() + c.subscribers = append(c.subscribers[:i], c.subscribers[i+1:]...) + c.subMutex.Unlock() + c.subMutex.RLock() + } + } +} + +func (c *Dump1090Client) startCleanup(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.cleanupStaleAircraft() + } + } +} + +func (c *Dump1090Client) cleanupStaleAircraft() { + c.mutex.Lock() + defer c.mutex.Unlock() + + cutoff := time.Now().Add(-2 * time.Minute) + trackCutoff := time.Now().Add(-24 * time.Hour) + + for hex, aircraft := range c.aircraftMap { + if aircraft.LastSeen.Before(cutoff) { + delete(c.aircraftMap, hex) + } else { + // Clean up old track points (keep last 24 hours) + validTracks := make([]parser.TrackPoint, 0) + for _, point := range aircraft.TrackHistory { + if point.Timestamp.After(trackCutoff) { + validTracks = append(validTracks, point) + } + } + aircraft.TrackHistory = validTracks + } + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..100034c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "strconv" +) + +type Config struct { + Server ServerConfig `json:"server"` + Dump1090 Dump1090Config `json:"dump1090"` + Origin OriginConfig `json:"origin"` +} + +type ServerConfig struct { + Address string `json:"address"` + Port int `json:"port"` +} + +type Dump1090Config struct { + Host string `json:"host"` + DataPort int `json:"data_port"` +} + +type OriginConfig struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` +} + +func Load() (*Config, error) { + cfg := &Config{ + Server: ServerConfig{ + Address: ":8080", + Port: 8080, + }, + Dump1090: Dump1090Config{ + Host: "localhost", + DataPort: 30003, + }, + Origin: OriginConfig{ + Latitude: 37.7749, + Longitude: -122.4194, + Name: "Default Location", + }, + } + + configFile := os.Getenv("SKYVIEW_CONFIG") + if configFile == "" { + // Check for config files in common locations + candidates := []string{"config.json", "./config.json", "skyview.json"} + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + configFile = candidate + break + } + } + } + + if configFile != "" { + if err := loadFromFile(cfg, configFile); err != nil { + return nil, fmt.Errorf("failed to load config file %s: %w", configFile, err) + } + } + + loadFromEnv(cfg) + + return cfg, nil +} + +func loadFromFile(cfg *Config, filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return err + } + + return json.Unmarshal(data, cfg) +} + +func loadFromEnv(cfg *Config) { + if addr := os.Getenv("SKYVIEW_ADDRESS"); addr != "" { + cfg.Server.Address = addr + } + + if portStr := os.Getenv("SKYVIEW_PORT"); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + cfg.Server.Port = port + cfg.Server.Address = fmt.Sprintf(":%d", port) + } + } + + if host := os.Getenv("DUMP1090_HOST"); host != "" { + cfg.Dump1090.Host = host + } + + if dataPortStr := os.Getenv("DUMP1090_DATA_PORT"); dataPortStr != "" { + if port, err := strconv.Atoi(dataPortStr); err == nil { + cfg.Dump1090.DataPort = port + } + } + + if latStr := os.Getenv("ORIGIN_LATITUDE"); latStr != "" { + if lat, err := strconv.ParseFloat(latStr, 64); err == nil { + cfg.Origin.Latitude = lat + } + } + + if lonStr := os.Getenv("ORIGIN_LONGITUDE"); lonStr != "" { + if lon, err := strconv.ParseFloat(lonStr, 64); err == nil { + cfg.Origin.Longitude = lon + } + } + + if name := os.Getenv("ORIGIN_NAME"); name != "" { + cfg.Origin.Name = name + } +} \ No newline at end of file diff --git a/internal/icao/database.go b/internal/icao/database.go deleted file mode 100644 index 4b125b9..0000000 --- a/internal/icao/database.go +++ /dev/null @@ -1,268 +0,0 @@ -package icao - -import ( - "sort" - "strconv" -) - -// Database handles ICAO address to country lookups -type Database struct { - allocations []ICAOAllocation -} - -// ICAOAllocation represents an ICAO address range allocation -type ICAOAllocation struct { - StartAddr int64 - EndAddr int64 - Country string - CountryCode string - Flag string - Description string -} - -// CountryInfo represents country information for an aircraft -type CountryInfo struct { - Country string `json:"country"` - CountryCode string `json:"country_code"` - Flag string `json:"flag"` -} - -// NewDatabase creates a new ICAO database with comprehensive allocation data -func NewDatabase() (*Database, error) { - allocations := getICAOAllocations() - - // Sort allocations by start address for efficient binary search - sort.Slice(allocations, func(i, j int) bool { - return allocations[i].StartAddr < allocations[j].StartAddr - }) - - return &Database{allocations: allocations}, nil -} - -// getICAOAllocations returns comprehensive ICAO allocation data based on official aerotransport.org table -func getICAOAllocations() []ICAOAllocation { - // ICAO allocations based on official ICAO 24-bit address allocation table - // Source: https://www.aerotransport.org/ (unofficial but comprehensive reference) - // Complete coverage of all allocated ICAO 24-bit addresses - return []ICAOAllocation{ - // Africa - {0x004000, 0x0043FF, "Zimbabwe", "ZW", "🇿🇼", "Republic of Zimbabwe"}, - {0x006000, 0x006FFF, "Mozambique", "MZ", "🇲🇿", "Republic of Mozambique"}, - {0x008000, 0x00FFFF, "South Africa", "ZA", "🇿🇦", "Republic of South Africa"}, - {0x010000, 0x017FFF, "Egypt", "EG", "🇪🇬", "Arab Republic of Egypt"}, - {0x018000, 0x01FFFF, "Libya", "LY", "🇱🇾", "State of Libya"}, - {0x020000, 0x027FFF, "Morocco", "MA", "🇲🇦", "Kingdom of Morocco"}, - {0x028000, 0x02FFFF, "Tunisia", "TN", "🇹🇳", "Republic of Tunisia"}, - {0x030000, 0x0303FF, "Botswana", "BW", "🇧🇼", "Republic of Botswana"}, - {0x032000, 0x032FFF, "Burundi", "BI", "🇧🇮", "Republic of Burundi"}, - {0x034000, 0x034FFF, "Cameroon", "CM", "🇨🇲", "Republic of Cameroon"}, - {0x035000, 0x0353FF, "Comoros", "KM", "🇰🇲", "Union of the Comoros"}, - {0x036000, 0x036FFF, "Congo", "CG", "🇨🇬", "Republic of the Congo"}, - {0x038000, 0x038FFF, "Côte d'Ivoire", "CI", "🇨🇮", "Republic of Côte d'Ivoire"}, - {0x03E000, 0x03EFFF, "Gabon", "GA", "🇬🇦", "Gabonese Republic"}, - {0x040000, 0x040FFF, "Ethiopia", "ET", "🇪🇹", "Federal Democratic Republic of Ethiopia"}, - {0x042000, 0x042FFF, "Equatorial Guinea", "GQ", "🇬🇶", "Republic of Equatorial Guinea"}, - {0x044000, 0x044FFF, "Ghana", "GH", "🇬🇭", "Republic of Ghana"}, - {0x046000, 0x046FFF, "Guinea", "GN", "🇬🇳", "Republic of Guinea"}, - {0x048000, 0x0483FF, "Guinea-Bissau", "GW", "🇬🇼", "Republic of Guinea-Bissau"}, - {0x04A000, 0x04A3FF, "Lesotho", "LS", "🇱🇸", "Kingdom of Lesotho"}, - {0x04C000, 0x04CFFF, "Kenya", "KE", "🇰🇪", "Republic of Kenya"}, - {0x050000, 0x050FFF, "Liberia", "LR", "🇱🇷", "Republic of Liberia"}, - {0x054000, 0x054FFF, "Madagascar", "MG", "🇲🇬", "Republic of Madagascar"}, - {0x058000, 0x058FFF, "Malawi", "MW", "🇲🇼", "Republic of Malawi"}, - {0x05C000, 0x05CFFF, "Mali", "ML", "🇲🇱", "Republic of Mali"}, - {0x05E000, 0x05E3FF, "Mauritania", "MR", "🇲🇷", "Islamic Republic of Mauritania"}, - {0x060000, 0x0603FF, "Mauritius", "MU", "🇲🇺", "Republic of Mauritius"}, - {0x062000, 0x062FFF, "Niger", "NE", "🇳🇪", "Republic of Niger"}, - {0x064000, 0x064FFF, "Nigeria", "NG", "🇳🇬", "Federal Republic of Nigeria"}, - {0x068000, 0x068FFF, "Uganda", "UG", "🇺🇬", "Republic of Uganda"}, - {0x06C000, 0x06CFFF, "Central African Republic", "CF", "🇨🇫", "Central African Republic"}, - {0x06E000, 0x06EFFF, "Rwanda", "RW", "🇷🇼", "Republic of Rwanda"}, - {0x070000, 0x070FFF, "Senegal", "SN", "🇸🇳", "Republic of Senegal"}, - {0x074000, 0x0743FF, "Seychelles", "SC", "🇸🇨", "Republic of Seychelles"}, - {0x076000, 0x0763FF, "Sierra Leone", "SL", "🇸🇱", "Republic of Sierra Leone"}, - {0x078000, 0x078FFF, "Somalia", "SO", "🇸🇴", "Federal Republic of Somalia"}, - {0x07A000, 0x07A3FF, "Swaziland", "SZ", "🇸🇿", "Kingdom of Swaziland"}, - {0x07C000, 0x07CFFF, "Sudan", "SD", "🇸🇩", "Republic of Sudan"}, - {0x080000, 0x080FFF, "Tanzania", "TZ", "🇹🇿", "United Republic of Tanzania"}, - {0x084000, 0x084FFF, "Chad", "TD", "🇹🇩", "Republic of Chad"}, - {0x088000, 0x088FFF, "Togo", "TG", "🇹🇬", "Togolese Republic"}, - {0x08A000, 0x08AFFF, "Zambia", "ZM", "🇿🇲", "Republic of Zambia"}, - {0x08C000, 0x08CFFF, "D R Congo", "CD", "🇨🇩", "Democratic Republic of the Congo"}, - {0x090000, 0x090FFF, "Angola", "AO", "🇦🇴", "Republic of Angola"}, - {0x094000, 0x0943FF, "Benin", "BJ", "🇧🇯", "Republic of Benin"}, - {0x096000, 0x0963FF, "Cape Verde", "CV", "🇨🇻", "Republic of Cape Verde"}, - {0x098000, 0x0983FF, "Djibouti", "DJ", "🇩🇯", "Republic of Djibouti"}, - {0x0A8000, 0x0A8FFF, "Bahamas", "BS", "🇧🇸", "Commonwealth of the Bahamas"}, - {0x0AA000, 0x0AA3FF, "Barbados", "BB", "🇧🇧", "Barbados"}, - {0x0AB000, 0x0AB3FF, "Belize", "BZ", "🇧🇿", "Belize"}, - {0x0B0000, 0x0B0FFF, "Cuba", "CU", "🇨🇺", "Republic of Cuba"}, - {0x0B2000, 0x0B2FFF, "El Salvador", "SV", "🇸🇻", "Republic of El Salvador"}, - {0x0B8000, 0x0B8FFF, "Haiti", "HT", "🇭🇹", "Republic of Haiti"}, - {0x0BA000, 0x0BAFFF, "Honduras", "HN", "🇭🇳", "Republic of Honduras"}, - {0x0BC000, 0x0BC3FF, "St. Vincent + Grenadines", "VC", "🇻🇨", "Saint Vincent and the Grenadines"}, - {0x0BE000, 0x0BEFFF, "Jamaica", "JM", "🇯🇲", "Jamaica"}, - {0x0D0000, 0x0D7FFF, "Mexico", "MX", "🇲🇽", "United Mexican States"}, - - // Eastern Europe & Russia - {0x100000, 0x1FFFFF, "Russia", "RU", "🇷🇺", "Russian Federation"}, - {0x201000, 0x2013FF, "Namibia", "NA", "🇳🇦", "Republic of Namibia"}, - {0x202000, 0x2023FF, "Eritrea", "ER", "🇪🇷", "State of Eritrea"}, - - // Europe - {0x300000, 0x33FFFF, "Italy", "IT", "🇮🇹", "Italian Republic"}, - {0x340000, 0x37FFFF, "Spain", "ES", "🇪🇸", "Kingdom of Spain"}, - {0x380000, 0x3BFFFF, "France", "FR", "🇫🇷", "French Republic"}, - {0x3C0000, 0x3FFFFF, "Germany", "DE", "🇩🇪", "Federal Republic of Germany"}, - {0x400000, 0x43FFFF, "United Kingdom", "GB", "🇬🇧", "United Kingdom"}, - {0x440000, 0x447FFF, "Austria", "AT", "🇦🇹", "Republic of Austria"}, - {0x448000, 0x44FFFF, "Belgium", "BE", "🇧🇪", "Kingdom of Belgium"}, - {0x450000, 0x457FFF, "Bulgaria", "BG", "🇧🇬", "Republic of Bulgaria"}, - {0x458000, 0x45FFFF, "Denmark", "DK", "🇩🇰", "Kingdom of Denmark"}, - {0x460000, 0x467FFF, "Finland", "FI", "🇫🇮", "Republic of Finland"}, - {0x468000, 0x46FFFF, "Greece", "GR", "🇬🇷", "Hellenic Republic"}, - {0x470000, 0x477FFF, "Hungary", "HU", "🇭🇺", "Republic of Hungary"}, - {0x478000, 0x47FFFF, "Norway", "NO", "🇳🇴", "Kingdom of Norway"}, - {0x480000, 0x487FFF, "Netherlands", "NL", "🇳🇱", "Kingdom of the Netherlands"}, - {0x488000, 0x48FFFF, "Poland", "PL", "🇵🇱", "Republic of Poland"}, - {0x490000, 0x497FFF, "Portugal", "PT", "🇵🇹", "Portuguese Republic"}, - {0x498000, 0x49FFFF, "Czech Republic", "CZ", "🇨🇿", "Czech Republic"}, - {0x4A0000, 0x4A7FFF, "Romania", "RO", "🇷🇴", "Romania"}, - {0x4A8000, 0x4AFFFF, "Sweden", "SE", "🇸🇪", "Kingdom of Sweden"}, - {0x4B0000, 0x4B7FFF, "Switzerland", "CH", "🇨🇭", "Swiss Confederation"}, - {0x4B8000, 0x4BFFFF, "Turkey", "TR", "🇹🇷", "Republic of Turkey"}, - {0x4C0000, 0x4C7FFF, "Yugoslavia", "YU", "🇷🇸", "Yugoslavia"}, - {0x4C8000, 0x4C83FF, "Cyprus", "CY", "🇨🇾", "Republic of Cyprus"}, - {0x4CA000, 0x4CAFFF, "Ireland", "IE", "🇮🇪", "Republic of Ireland"}, - {0x4CC000, 0x4CCFFF, "Iceland", "IS", "🇮🇸", "Republic of Iceland"}, - {0x4D0000, 0x4D03FF, "Luxembourg", "LU", "🇱🇺", "Grand Duchy of Luxembourg"}, - {0x4D2000, 0x4D23FF, "Malta", "MT", "🇲🇹", "Republic of Malta"}, - {0x4D4000, 0x4D43FF, "Monaco", "MC", "🇲🇨", "Principality of Monaco"}, - {0x500000, 0x5004FF, "San Marino", "SM", "🇸🇲", "Republic of San Marino"}, - {0x501000, 0x5013FF, "Albania", "AL", "🇦🇱", "Republic of Albania"}, - {0x501C00, 0x501FFF, "Croatia", "HR", "🇭🇷", "Republic of Croatia"}, - {0x502C00, 0x502FFF, "Latvia", "LV", "🇱🇻", "Republic of Latvia"}, - {0x503C00, 0x503FFF, "Lithuania", "LT", "🇱🇹", "Republic of Lithuania"}, - {0x504C00, 0x504FFF, "Moldova", "MD", "🇲🇩", "Republic of Moldova"}, - {0x505C00, 0x505FFF, "Slovakia", "SK", "🇸🇰", "Slovak Republic"}, - {0x506C00, 0x506FFF, "Slovenia", "SI", "🇸🇮", "Republic of Slovenia"}, - {0x508000, 0x50FFFF, "Ukraine", "UA", "🇺🇦", "Ukraine"}, - {0x510000, 0x5103FF, "Belarus", "BY", "🇧🇾", "Republic of Belarus"}, - {0x511000, 0x5113FF, "Estonia", "EE", "🇪🇪", "Republic of Estonia"}, - {0x512000, 0x5123FF, "Macedonia", "MK", "🇲🇰", "North Macedonia"}, - {0x513000, 0x5133FF, "Bosnia & Herzegovina", "BA", "🇧🇦", "Bosnia and Herzegovina"}, - {0x514000, 0x5143FF, "Georgia", "GE", "🇬🇪", "Georgia"}, - - // Middle East & Central Asia - {0x600000, 0x6003FF, "Armenia", "AM", "🇦🇲", "Republic of Armenia"}, - {0x600800, 0x600BFF, "Azerbaijan", "AZ", "🇦🇿", "Republic of Azerbaijan"}, - {0x680000, 0x6803FF, "Bhutan", "BT", "🇧🇹", "Kingdom of Bhutan"}, - {0x681000, 0x6813FF, "Micronesia", "FM", "🇫🇲", "Federated States of Micronesia"}, - {0x682000, 0x6823FF, "Mongolia", "MN", "🇲🇳", "Mongolia"}, - {0x683000, 0x6833FF, "Kazakhstan", "KZ", "🇰🇿", "Republic of Kazakhstan"}, - {0x06A000, 0x06A3FF, "Qatar", "QA", "🇶🇦", "State of Qatar"}, - {0x700000, 0x700FFF, "Afghanistan", "AF", "🇦🇫", "Islamic Republic of Afghanistan"}, - {0x702000, 0x702FFF, "Bangladesh", "BD", "🇧🇩", "People's Republic of Bangladesh"}, - {0x704000, 0x704FFF, "Myanmar", "MM", "🇲🇲", "Republic of the Union of Myanmar"}, - {0x706000, 0x706FFF, "Kuwait", "KW", "🇰🇼", "State of Kuwait"}, - {0x708000, 0x708FFF, "Laos", "LA", "🇱🇦", "Lao People's Democratic Republic"}, - {0x70A000, 0x70AFFF, "Nepal", "NP", "🇳🇵", "Federal Democratic Republic of Nepal"}, - {0x70C000, 0x70C3FF, "Oman", "OM", "🇴🇲", "Sultanate of Oman"}, - {0x70E000, 0x70EFFF, "Cambodia", "KH", "🇰🇭", "Kingdom of Cambodia"}, - {0x710000, 0x717FFF, "Saudi Arabia", "SA", "🇸🇦", "Kingdom of Saudi Arabia"}, - {0x718000, 0x71FFFF, "South Korea", "KR", "🇰🇷", "Republic of Korea"}, - {0x720000, 0x727FFF, "North Korea", "KP", "🇰🇵", "Democratic People's Republic of Korea"}, - {0x728000, 0x72FFFF, "Iraq", "IQ", "🇮🇶", "Republic of Iraq"}, - {0x730000, 0x737FFF, "Iran", "IR", "🇮🇷", "Islamic Republic of Iran"}, - {0x738000, 0x73FFFF, "Israel", "IL", "🇮🇱", "State of Israel"}, - {0x740000, 0x747FFF, "Jordan", "JO", "🇯🇴", "Hashemite Kingdom of Jordan"}, - {0x750000, 0x757FFF, "Malaysia", "MY", "🇲🇾", "Malaysia"}, - {0x758000, 0x75FFFF, "Philippines", "PH", "🇵🇭", "Republic of the Philippines"}, - {0x760000, 0x767FFF, "Pakistan", "PK", "🇵🇰", "Islamic Republic of Pakistan"}, - {0x768000, 0x76FFFF, "Singapore", "SG", "🇸🇬", "Republic of Singapore"}, - {0x770000, 0x777FFF, "Sri Lanka", "LK", "🇱🇰", "Democratic Socialist Republic of Sri Lanka"}, - {0x778000, 0x77FFFF, "Syria", "SY", "🇸🇾", "Syrian Arab Republic"}, - {0x780000, 0x7BFFFF, "China", "CN", "🇨🇳", "People's Republic of China"}, - {0x7C0000, 0x7FFFFF, "Australia", "AU", "🇦🇺", "Commonwealth of Australia"}, - - // Asia-Pacific - {0x800000, 0x83FFFF, "India", "IN", "🇮🇳", "Republic of India"}, - {0x840000, 0x87FFFF, "Japan", "JP", "🇯🇵", "Japan"}, - {0x880000, 0x887FFF, "Thailand", "TH", "🇹🇭", "Kingdom of Thailand"}, - {0x888000, 0x88FFFF, "Vietnam", "VN", "🇻🇳", "Socialist Republic of Vietnam"}, - {0x890000, 0x890FFF, "Yemen", "YE", "🇾🇪", "Republic of Yemen"}, - {0x894000, 0x894FFF, "Bahrain", "BH", "🇧🇭", "Kingdom of Bahrain"}, - {0x895000, 0x8953FF, "Brunei", "BN", "🇧🇳", "Nation of Brunei"}, - {0x896000, 0x8973FF, "United Arab Emirates", "AE", "🇦🇪", "United Arab Emirates"}, - {0x897000, 0x8973FF, "Solomon Islands", "SB", "🇸🇧", "Solomon Islands"}, - {0x898000, 0x898FFF, "Papua New Guinea", "PG", "🇵🇬", "Independent State of Papua New Guinea"}, - {0x899000, 0x8993FF, "Taiwan", "TW", "🇹🇼", "Republic of China (Taiwan)"}, - {0x8A0000, 0x8A7FFF, "Indonesia", "ID", "🇮🇩", "Republic of Indonesia"}, - - // North America - {0xA00000, 0xAFFFFF, "United States", "US", "🇺🇸", "United States of America"}, - - // North America & Oceania - {0xC00000, 0xC3FFFF, "Canada", "CA", "🇨🇦", "Canada"}, - {0xC80000, 0xC87FFF, "New Zealand", "NZ", "🇳🇿", "New Zealand"}, - {0xC88000, 0xC88FFF, "Fiji", "FJ", "🇫🇯", "Republic of Fiji"}, - {0xC8A000, 0xC8A3FF, "Nauru", "NR", "🇳🇷", "Republic of Nauru"}, - {0xC8C000, 0xC8C3FF, "Saint Lucia", "LC", "🇱🇨", "Saint Lucia"}, - {0xC8D000, 0xC8D3FF, "Tonga", "TO", "🇹🇴", "Kingdom of Tonga"}, - {0xC8E000, 0xC8E3FF, "Kiribati", "KI", "🇰🇮", "Republic of Kiribati"}, - - // South America - {0xE00000, 0xE3FFFF, "Argentina", "AR", "🇦🇷", "Argentine Republic"}, - {0xE40000, 0xE7FFFF, "Brazil", "BR", "🇧🇷", "Federative Republic of Brazil"}, - {0xE80000, 0xE80FFF, "Chile", "CL", "🇨🇱", "Republic of Chile"}, - {0xE84000, 0xE84FFF, "Ecuador", "EC", "🇪🇨", "Republic of Ecuador"}, - {0xE88000, 0xE88FFF, "Paraguay", "PY", "🇵🇾", "Republic of Paraguay"}, - {0xE8C000, 0xE8CFFF, "Peru", "PE", "🇵🇪", "Republic of Peru"}, - {0xE90000, 0xE90FFF, "Uruguay", "UY", "🇺🇾", "Oriental Republic of Uruguay"}, - {0xE94000, 0xE94FFF, "Bolivia", "BO", "🇧🇴", "Plurinational State of Bolivia"}, - } -} - -// LookupCountry returns country information for an ICAO address using binary search -func (d *Database) LookupCountry(icaoHex string) (*CountryInfo, error) { - if len(icaoHex) != 6 { - return &CountryInfo{ - Country: "Unknown", - CountryCode: "XX", - Flag: "🏳️", - }, nil - } - - // Convert hex string to integer - icaoInt, err := strconv.ParseInt(icaoHex, 16, 64) - if err != nil { - return &CountryInfo{ - Country: "Unknown", - CountryCode: "XX", - Flag: "🏳️", - }, nil - } - - // Binary search for the ICAO address range - for _, alloc := range d.allocations { - if icaoInt >= alloc.StartAddr && icaoInt <= alloc.EndAddr { - return &CountryInfo{ - Country: alloc.Country, - CountryCode: alloc.CountryCode, - Flag: alloc.Flag, - }, nil - } - } - - // Not found in any allocation - return &CountryInfo{ - Country: "Unknown", - CountryCode: "XX", - Flag: "🏳️", - }, nil -} - -// Close is a no-op since we don't have any resources to clean up -func (d *Database) Close() error { - return nil -} diff --git a/internal/merger/merger.go b/internal/merger/merger.go deleted file mode 100644 index 2b245ba..0000000 --- a/internal/merger/merger.go +++ /dev/null @@ -1,1034 +0,0 @@ -// Package merger provides multi-source aircraft data fusion and conflict resolution. -// -// This package is the core of SkyView's multi-source capability, handling the complex -// task of merging aircraft data from multiple ADS-B receivers. It provides: -// - Intelligent data fusion based on signal strength and recency -// - Historical tracking of aircraft positions, altitudes, and speeds -// - Per-source signal quality and update rate tracking -// - Automatic conflict resolution when sources disagree -// - Comprehensive aircraft state management -// - Distance and bearing calculations from receivers -// -// The merger uses several strategies for data fusion: -// - Position data: Uses source with strongest signal -// - Recent data: Prefers newer information for dynamic values -// - Best quality: Prioritizes higher accuracy navigation data -// - History tracking: Maintains trails for visualization and analysis -// -// All data structures are designed for concurrent access and JSON serialization -// for web API consumption. -package merger - -import ( - "encoding/json" - "fmt" - "log" - "math" - "sync" - "time" - - "skyview/internal/icao" - "skyview/internal/modes" -) - -const ( - // MaxDistance represents an infinite distance for initialization - MaxDistance = float64(999999) - - // Position validation constants - MaxSpeedKnots = 2000.0 // Maximum plausible aircraft speed (roughly Mach 3 at cruise altitude) - MaxDistanceNautMiles = 500.0 // Maximum position jump distance in nautical miles - MaxAltitudeFeet = 60000 // Maximum altitude in feet (commercial ceiling ~FL600) - MinAltitudeFeet = -500 // Minimum altitude (below sea level but allow for dead sea, etc.) - - // Earth coordinate bounds - MinLatitude = -90.0 - MaxLatitude = 90.0 - MinLongitude = -180.0 - MaxLongitude = 180.0 - - // Conversion factors - KnotsToKmh = 1.852 - NmToKm = 1.852 -) - -// ValidationResult represents the result of position validation checks. -type ValidationResult struct { - Valid bool // Whether the position passed all validation checks - Errors []string // List of validation failures for debugging - Warnings []string // List of potential issues (not blocking) -} - -// Source represents a data source (dump1090 receiver or similar ADS-B source). -// It contains both static configuration and dynamic status information used -// for data fusion decisions and source monitoring. -type Source struct { - ID string `json:"id"` // Unique identifier for this source - Name string `json:"name"` // Human-readable name - Host string `json:"host"` // Hostname or IP address - Port int `json:"port"` // TCP port number - Latitude float64 `json:"latitude"` // Receiver location latitude - Longitude float64 `json:"longitude"` // Receiver location longitude - Altitude float64 `json:"altitude"` // Receiver altitude above sea level - Active bool `json:"active"` // Currently connected and receiving data - LastSeen time.Time `json:"last_seen"` // Timestamp of last received message - Messages int64 `json:"messages"` // Total messages processed from this source - Aircraft int `json:"aircraft"` // Current aircraft count from this source -} - -// AircraftState represents the complete merged aircraft state from all sources. -// -// This structure combines the basic aircraft data from Mode S decoding with -// multi-source metadata, historical tracking, and derived information: -// - Embedded modes.Aircraft with all decoded ADS-B data -// - Per-source signal and quality information -// - Historical trails for position, altitude, speed, and signal strength -// - Distance and bearing from receivers -// - Update rate and age calculations -// - Data provenance tracking -type AircraftState struct { - *modes.Aircraft // Embedded decoded aircraft data - Sources map[string]*SourceData `json:"sources"` // Per-source information - LastUpdate time.Time `json:"last_update"` // Last update from any source - FirstSeen time.Time `json:"first_seen"` // First time this aircraft was seen - TotalMessages int64 `json:"total_messages"` // Total messages received for this aircraft - PositionHistory []PositionPoint `json:"position_history"` // Trail of position updates - SignalHistory []SignalPoint `json:"signal_history"` // Signal strength over time - AltitudeHistory []AltitudePoint `json:"altitude_history"` // Altitude and vertical rate history - SpeedHistory []SpeedPoint `json:"speed_history"` // Speed and track history - Distance float64 `json:"distance"` // Distance from closest receiver (km) - Bearing float64 `json:"bearing"` // Bearing from closest receiver (degrees) - Age float64 `json:"age"` // Seconds since last update - MLATSources []string `json:"mlat_sources"` // Sources providing MLAT position data - PositionSource string `json:"position_source"` // Source providing current position - UpdateRate float64 `json:"update_rate"` // Recent updates per second - Country string `json:"country"` // Country of registration - CountryCode string `json:"country_code"` // ISO country code - Flag string `json:"flag"` // Country flag emoji -} - -// 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"` - TransponderCapability string `json:"TransponderCapability"` - TransponderLevel uint8 `json:"TransponderLevel"` - SignalQuality string `json:"SignalQuality"` - 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"` - Country string `json:"country"` - CountryCode string `json:"country_code"` - Flag string `json:"flag"` - }{ - // 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, - TransponderCapability: a.Aircraft.TransponderCapability, - TransponderLevel: a.Aircraft.TransponderLevel, - SignalQuality: a.Aircraft.SignalQuality, - 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, - Country: a.Country, - CountryCode: a.CountryCode, - Flag: a.Flag, - }) -} - -// 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 { - SourceID string `json:"source_id"` // Unique identifier of the source - SignalLevel float64 `json:"signal_level"` // Signal strength (dBFS) - Messages int64 `json:"messages"` // Messages received from this source - LastSeen time.Time `json:"last_seen"` // Last message timestamp from this source - Distance float64 `json:"distance"` // Distance from receiver to aircraft (km) - Bearing float64 `json:"bearing"` // Bearing from receiver to aircraft (degrees) - UpdateRate float64 `json:"update_rate"` // Updates per second from this source -} - -// PositionPoint represents a timestamped position update in aircraft history. -// Used to build position trails for visualization and track analysis. -type PositionPoint struct { - Time time.Time `json:"time"` // Timestamp when position was received - Latitude float64 `json:"lat"` // Latitude in decimal degrees - Longitude float64 `json:"lon"` // Longitude in decimal degrees - Source string `json:"source"` // Source that provided this position -} - -// SignalPoint represents a timestamped signal strength measurement. -// Used to track signal quality over time and analyze receiver performance. -type SignalPoint struct { - Time time.Time `json:"time"` // Timestamp when signal was measured - Signal float64 `json:"signal"` // Signal strength in dBFS - Source string `json:"source"` // Source that measured this signal -} - -// AltitudePoint represents a timestamped altitude measurement. -// Includes vertical rate for flight profile analysis. -type AltitudePoint struct { - Time time.Time `json:"time"` // Timestamp when altitude was received - Altitude int `json:"altitude"` // Altitude in feet - VRate int `json:"vrate"` // Vertical rate in feet per minute -} - -// SpeedPoint represents a timestamped speed and track measurement. -// Used for aircraft performance analysis and track prediction. -type SpeedPoint struct { - Time time.Time `json:"time"` // Timestamp when speed was received - GroundSpeed int `json:"ground_speed"` // Ground speed in knots (integer) - Track int `json:"track"` // Track angle in degrees (0-359) -} - -// Merger handles merging aircraft data from multiple sources with intelligent conflict resolution. -// -// The merger maintains: -// - Complete aircraft states with multi-source data fusion -// - Source registry with connection status and statistics -// - Historical data with configurable retention limits -// - Update rate metrics for performance monitoring -// - Automatic stale aircraft cleanup -// -// Thread safety is provided by RWMutex for concurrent read access while -// maintaining write consistency during updates. -type Merger struct { - aircraft map[uint32]*AircraftState // ICAO24 -> merged aircraft state - sources map[string]*Source // Source ID -> source information - icaoDB *icao.Database // ICAO country lookup database - mu sync.RWMutex // Protects all maps and slices - historyLimit int // Maximum history points to retain - staleTimeout time.Duration // Time before aircraft considered stale (15 seconds) - updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data - validationLog map[uint32]time.Time // ICAO24 -> last validation log time (rate limiting) -} - -// updateMetric tracks recent update times for calculating update rates. -// Used internally to provide real-time update frequency information. -type updateMetric struct { - updates []time.Time // Recent update timestamps (last 30 seconds) -} - -// NewMerger creates a new aircraft data merger with default configuration. -// -// Default settings: -// - History limit: 500 points per aircraft -// - Stale timeout: 15 seconds -// - Empty aircraft and source maps -// - Update metrics tracking enabled -// -// The merger is ready for immediate use after creation. -func NewMerger() (*Merger, error) { - icaoDB, err := icao.NewDatabase() - if err != nil { - return nil, fmt.Errorf("failed to initialize ICAO database: %w", err) - } - - return &Merger{ - aircraft: make(map[uint32]*AircraftState), - sources: make(map[string]*Source), - icaoDB: icaoDB, - historyLimit: 500, - staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking - updateMetrics: make(map[uint32]*updateMetric), - validationLog: make(map[uint32]time.Time), - }, nil -} - -// AddSource registers a new data source with the merger. -// -// The source must have a unique ID and will be available for aircraft -// data updates immediately. Sources can be added at any time, even -// after the merger is actively processing data. -// -// Parameters: -// - source: Source configuration with ID, location, and connection details -func (m *Merger) AddSource(source *Source) { - m.mu.Lock() - defer m.mu.Unlock() - m.sources[source.ID] = source -} - -// UpdateAircraft merges new aircraft data from a source using intelligent fusion strategies. -// -// This is the core method of the merger, handling: -// 1. Aircraft state creation for new aircraft -// 2. Source data tracking and statistics -// 3. Multi-source data fusion with conflict resolution -// 4. Historical data updates with retention limits -// 5. Distance and bearing calculations -// 6. Update rate metrics -// 7. Source status maintenance -// -// Data fusion strategies: -// - Position: Use source with strongest signal -// - Dynamic data: Prefer most recent updates -// - Quality indicators: Keep highest accuracy values -// - Identity: Use most recent non-empty values -// -// Parameters: -// - sourceID: Identifier of the source providing this data -// - aircraft: Decoded Mode S/ADS-B aircraft data -// - signal: Signal strength in dBFS -// - timestamp: When this data was received -func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signal float64, timestamp time.Time) { - m.mu.Lock() - defer m.mu.Unlock() - - // Get or create aircraft state - state, exists := m.aircraft[aircraft.ICAO24] - if !exists { - state = &AircraftState{ - Aircraft: aircraft, - Sources: make(map[string]*SourceData), - FirstSeen: timestamp, - PositionHistory: make([]PositionPoint, 0), - SignalHistory: make([]SignalPoint, 0), - AltitudeHistory: make([]AltitudePoint, 0), - SpeedHistory: make([]SpeedPoint, 0), - } - - // Lookup country information for new aircraft - icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24) - if countryInfo, err := m.icaoDB.LookupCountry(icaoHex); err == nil { - state.Country = countryInfo.Country - state.CountryCode = countryInfo.CountryCode - state.Flag = countryInfo.Flag - } else { - // Fallback to unknown if lookup fails - state.Country = "Unknown" - state.CountryCode = "XX" - state.Flag = "🏳️" - } - - m.aircraft[aircraft.ICAO24] = state - m.updateMetrics[aircraft.ICAO24] = &updateMetric{ - 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] - if !srcExists { - srcData = &SourceData{ - SourceID: sourceID, - } - state.Sources[sourceID] = srcData - } - - // Update source data - srcData.SignalLevel = signal - srcData.Messages++ - srcData.LastSeen = timestamp - - // Calculate distance and bearing from source - if source, ok := m.sources[sourceID]; ok && aircraft.Latitude != 0 && aircraft.Longitude != 0 { - srcData.Distance, srcData.Bearing = calculateDistanceBearing( - source.Latitude, source.Longitude, - aircraft.Latitude, aircraft.Longitude, - ) - } - - // Update merged aircraft data (use best/newest data) - m.mergeAircraftData(state, aircraft, sourceID, timestamp) - - // Update histories - m.updateHistories(state, aircraft, sourceID, signal, timestamp) - - // Update metrics - m.updateUpdateRate(aircraft.ICAO24, timestamp) - - // Update source statistics - if source, ok := m.sources[sourceID]; ok { - source.LastSeen = timestamp - source.Messages++ - source.Active = true - } - - state.LastUpdate = timestamp - state.TotalMessages++ -} - -// mergeAircraftData intelligently merges data from multiple sources with conflict resolution. -// -// This method implements the core data fusion logic: -// -// Position Data: -// - Uses source with strongest signal strength for best accuracy -// - Falls back to any available position if none exists -// - Tracks which source provided the current position -// -// Dynamic Data (altitude, speed, heading, vertical rate): -// - Always uses most recent data to reflect current aircraft state -// - Assumes more recent data is more accurate for rapidly changing values -// -// Identity Data (callsign, squawk, category): -// - Uses most recent non-empty values -// - Preserves existing values when new data is empty -// -// Quality Indicators (NACp, NACv, SIL): -// - Uses highest available accuracy values -// - Maintains best quality indicators across all sources -// -// Parameters: -// - state: Current merged aircraft state to update -// - new: New aircraft data from a source -// - sourceID: Identifier of source providing new data -// - timestamp: Timestamp of new data -func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, sourceID string, timestamp time.Time) { - // Position - use source with best signal or most recent, but validate first - if new.Latitude != 0 && new.Longitude != 0 { - // Always validate position before considering update - validation := m.validatePosition(new, state, timestamp) - - if !validation.Valid { - // Rate-limited logging: only log once every 10 seconds per aircraft - if lastLog, exists := m.validationLog[new.ICAO24]; !exists || timestamp.Sub(lastLog) > 10*time.Second { - icaoHex := fmt.Sprintf("%06X", new.ICAO24) - // Only log first error to reduce spam - if len(validation.Errors) > 0 { - log.Printf("[POSITION_VALIDATION] ICAO %s: REJECTED - %s", icaoHex, validation.Errors[0]) - m.validationLog[new.ICAO24] = timestamp - } - } - } else { - // Position is valid, proceed with normal logic - 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 - } - } - - if updatePosition { - state.Latitude = new.Latitude - state.Longitude = new.Longitude - state.PositionSource = sourceID - } - } - - // Rate-limited warning logging - if len(validation.Warnings) > 0 { - // Only log warnings once every 30 seconds per aircraft - warningKey := new.ICAO24 + 0x10000000 // Offset to differentiate from error logging - if lastLog, exists := m.validationLog[warningKey]; !exists || timestamp.Sub(lastLog) > 30*time.Second { - icaoHex := fmt.Sprintf("%06X", new.ICAO24) - log.Printf("[POSITION_VALIDATION] ICAO %s: WARNING - %s", icaoHex, validation.Warnings[0]) - m.validationLog[warningKey] = timestamp - } - } - } - - // Altitude - use most recent - if new.Altitude != 0 { - state.Altitude = new.Altitude - } - if new.BaroAltitude != 0 { - state.BaroAltitude = new.BaroAltitude - } - if new.GeomAltitude != 0 { - state.GeomAltitude = new.GeomAltitude - } - - // Speed and track - use most recent - if new.GroundSpeed != 0 { - state.GroundSpeed = new.GroundSpeed - } - if new.Track != 0 { - state.Track = new.Track - } - if new.Heading != 0 { - state.Heading = new.Heading - } - - // Vertical rate - use most recent - if new.VerticalRate != 0 { - state.VerticalRate = new.VerticalRate - } - - // Identity - use most recent non-empty - if new.Callsign != "" { - state.Callsign = new.Callsign - } - if new.Squawk != "" { - state.Squawk = new.Squawk - } - if new.Category != "" { - state.Category = new.Category - } - - // Status - use most recent - if new.Emergency != "" { - state.Emergency = new.Emergency - } - state.OnGround = new.OnGround - state.Alert = new.Alert - state.SPI = new.SPI - - // Navigation accuracy - use best available - if new.NACp > state.NACp { - state.NACp = new.NACp - } - if new.NACv > state.NACv { - state.NACv = new.NACv - } - if new.SIL > state.SIL { - state.SIL = new.SIL - } - - // Selected values - use most recent - if new.SelectedAltitude != 0 { - state.SelectedAltitude = new.SelectedAltitude - } - if new.SelectedHeading != 0 { - state.SelectedHeading = new.SelectedHeading - } - if new.BaroSetting != 0 { - state.BaroSetting = new.BaroSetting - } - - // Transponder information - use most recent non-empty - if new.TransponderCapability != "" { - state.TransponderCapability = new.TransponderCapability - } - 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 - shouldUpdate := state.SignalQuality == "" || - (new.SignalQuality == "Excellent") || - (new.SignalQuality == "Good" && state.SignalQuality != "Excellent") || - (new.SignalQuality == "Fair" && state.SignalQuality == "Poor") - - if shouldUpdate { - state.SignalQuality = new.SignalQuality - } - } -} - -// updateHistories adds data points to historical tracking arrays. -// -// Maintains time-series data for: -// - Position trail for track visualization -// - Signal strength for coverage analysis -// - Altitude profile for flight analysis -// - Speed history for performance tracking -// -// Each history array is limited by historyLimit to prevent unbounded growth. -// Only non-zero values are recorded to avoid cluttering histories with -// invalid or missing data points. -// -// Parameters: -// - state: Aircraft state to update histories for -// - aircraft: New aircraft data containing values to record -// - sourceID: Source providing this data point -// - signal: Signal strength measurement -// - timestamp: When this data was received -func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) { - // Position history with validation - if aircraft.Latitude != 0 && aircraft.Longitude != 0 { - // Validate position before adding to history - validation := m.validatePosition(aircraft, state, timestamp) - - if validation.Valid { - state.PositionHistory = append(state.PositionHistory, PositionPoint{ - Time: timestamp, - Latitude: aircraft.Latitude, - Longitude: aircraft.Longitude, - Source: sourceID, - }) - } - // Note: Validation errors/warnings are already logged in mergeAircraftData - } - - // Signal history - if signal != 0 { - state.SignalHistory = append(state.SignalHistory, SignalPoint{ - Time: timestamp, - Signal: signal, - Source: sourceID, - }) - } - - // Altitude history - if aircraft.Altitude != 0 { - state.AltitudeHistory = append(state.AltitudeHistory, AltitudePoint{ - Time: timestamp, - Altitude: aircraft.Altitude, - VRate: aircraft.VerticalRate, - }) - } - - // Speed history - if aircraft.GroundSpeed != 0 { - state.SpeedHistory = append(state.SpeedHistory, SpeedPoint{ - Time: timestamp, - GroundSpeed: aircraft.GroundSpeed, - Track: aircraft.Track, - }) - } - - // Trim histories if they exceed limit - if len(state.PositionHistory) > m.historyLimit { - state.PositionHistory = state.PositionHistory[len(state.PositionHistory)-m.historyLimit:] - } - if len(state.SignalHistory) > m.historyLimit { - state.SignalHistory = state.SignalHistory[len(state.SignalHistory)-m.historyLimit:] - } - if len(state.AltitudeHistory) > m.historyLimit { - state.AltitudeHistory = state.AltitudeHistory[len(state.AltitudeHistory)-m.historyLimit:] - } - if len(state.SpeedHistory) > m.historyLimit { - state.SpeedHistory = state.SpeedHistory[len(state.SpeedHistory)-m.historyLimit:] - } -} - -// updateUpdateRate calculates and maintains the message update rate for an aircraft. -// -// The calculation: -// 1. Records the timestamp of each update -// 2. Maintains a sliding 30-second window of updates -// 3. Calculates updates per second over this window -// 4. Updates the aircraft's UpdateRate field -// -// This provides real-time feedback on data quality and can help identify -// aircraft that are updating frequently (close, good signal) vs infrequently -// (distant, weak signal). -// -// Parameters: -// - icao: ICAO24 address of the aircraft -// - timestamp: Timestamp of this update -func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) { - metric := m.updateMetrics[icao] - metric.updates = append(metric.updates, timestamp) - - // Keep only last 30 seconds of updates - cutoff := timestamp.Add(-30 * time.Second) - for len(metric.updates) > 0 && metric.updates[0].Before(cutoff) { - metric.updates = metric.updates[1:] - } - - if len(metric.updates) > 1 { - duration := metric.updates[len(metric.updates)-1].Sub(metric.updates[0]).Seconds() - if duration > 0 { - if state, ok := m.aircraft[icao]; ok { - state.UpdateRate = float64(len(metric.updates)) / duration - } - } - } -} - -// getBestSignalSource identifies the source with the strongest signal for this aircraft. -// -// Used in position data fusion to determine which source should provide -// the authoritative position. Sources with stronger signals typically -// provide more accurate position data. -// -// Parameters: -// - state: Aircraft state containing per-source signal data -// -// Returns the source ID with the highest signal level, or empty string if none. -func (m *Merger) getBestSignalSource(state *AircraftState) string { - var bestSource string - var bestSignal float64 = -999 - - for srcID, srcData := range state.Sources { - if srcData.SignalLevel > bestSignal { - bestSignal = srcData.SignalLevel - bestSource = srcID - } - } - - return bestSource -} - -// GetAircraft returns a snapshot of all current aircraft states. -// -// This method: -// 1. Filters out stale aircraft (older than staleTimeout) -// 2. Calculates current age for each aircraft -// 3. Determines closest receiver distance and bearing -// 4. Returns copies to prevent external modification -// -// The returned map uses ICAO24 addresses as keys and can be safely -// used by multiple goroutines without affecting the internal state. -// -// Returns a map of ICAO24 -> AircraftState for all non-stale aircraft. -func (m *Merger) GetAircraft() map[uint32]*AircraftState { - m.mu.RLock() - defer m.mu.RUnlock() - - // Create copy and calculate ages - result := make(map[uint32]*AircraftState) - now := time.Now() - - for icao, state := range m.aircraft { - // Skip stale aircraft - if now.Sub(state.LastUpdate) > m.staleTimeout { - continue - } - - // Calculate age - stateCopy := *state - stateCopy.Age = now.Sub(state.LastUpdate).Seconds() - - // Find closest receiver distance - minDistance := MaxDistance - for _, srcData := range state.Sources { - if srcData.Distance > 0 && srcData.Distance < minDistance { - minDistance = srcData.Distance - stateCopy.Distance = srcData.Distance - stateCopy.Bearing = srcData.Bearing - } - } - - result[icao] = &stateCopy - } - - return result -} - -// GetSources returns all registered data sources. -// -// Provides access to source configuration, status, and statistics. -// Used by the web API to display source information and connection status. -// -// Returns a slice of all registered sources (active and inactive). -func (m *Merger) GetSources() []*Source { - m.mu.RLock() - defer m.mu.RUnlock() - - sources := make([]*Source, 0, len(m.sources)) - for _, src := range m.sources { - sources = append(sources, src) - } - return sources -} - -// GetStatistics returns comprehensive merger and system statistics. -// -// The statistics include: -// - total_aircraft: Current number of tracked aircraft -// - total_messages: Sum of all messages processed -// - active_sources: Number of currently connected sources -// - aircraft_by_sources: Distribution of aircraft by number of tracking sources -// -// The aircraft_by_sources map shows data quality - aircraft tracked by -// multiple sources generally have better position accuracy and reliability. -// -// Returns a map suitable for JSON serialization and web display. -func (m *Merger) GetStatistics() map[string]interface{} { - m.mu.RLock() - defer m.mu.RUnlock() - - totalMessages := int64(0) - activeSources := 0 - aircraftBySources := make(map[int]int) // Count by number of sources - - for _, state := range m.aircraft { - totalMessages += state.TotalMessages - numSources := len(state.Sources) - aircraftBySources[numSources]++ - } - - for _, src := range m.sources { - if src.Active { - activeSources++ - } - } - - return map[string]interface{}{ - "total_aircraft": len(m.aircraft), - "total_messages": totalMessages, - "active_sources": activeSources, - "aircraft_by_sources": aircraftBySources, - } -} - -// CleanupStale removes aircraft that haven't been updated recently. -// -// Aircraft are considered stale if they haven't received updates for longer -// 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. -// This method is typically called periodically by the client manager. -func (m *Merger) CleanupStale() { - m.mu.Lock() - defer m.mu.Unlock() - - now := time.Now() - for icao, state := range m.aircraft { - if now.Sub(state.LastUpdate) > m.staleTimeout { - delete(m.aircraft, icao) - delete(m.updateMetrics, icao) - // Clean up validation log entries - delete(m.validationLog, icao) - delete(m.validationLog, icao+0x10000000) // Warning key - } - } -} - -// calculateDistanceBearing computes great circle distance and bearing between two points. -// -// Uses the Haversine formula for distance calculation and forward azimuth -// for bearing calculation. Both calculations account for Earth's spherical -// nature for accuracy over long distances. -// -// Distance is calculated in kilometers, bearing in degrees (0-360° from North). -// This is used to calculate aircraft distance from receivers and for -// coverage analysis. -// -// Parameters: -// - lat1, lon1: First point (receiver) coordinates in decimal degrees -// - lat2, lon2: Second point (aircraft) coordinates in decimal degrees -// -// Returns: -// - distance: Great circle distance in kilometers -// - bearing: Forward azimuth in degrees (0° = North, 90° = East) -func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64) { - // Haversine formula for distance - const R = 6371.0 // Earth radius in km - - dLat := (lat2 - lat1) * math.Pi / 180 - dLon := (lon2 - lon1) * math.Pi / 180 - - a := math.Sin(dLat/2)*math.Sin(dLat/2) + - math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)* - math.Sin(dLon/2)*math.Sin(dLon/2) - - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - distance := R * c - - // Bearing calculation - y := math.Sin(dLon) * math.Cos(lat2*math.Pi/180) - x := math.Cos(lat1*math.Pi/180)*math.Sin(lat2*math.Pi/180) - - math.Sin(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Cos(dLon) - - bearing := math.Atan2(y, x) * 180 / math.Pi - if bearing < 0 { - bearing += 360 - } - - return distance, bearing -} - -// validatePosition performs comprehensive validation of aircraft position data to filter out -// obviously incorrect flight paths and implausible position updates. -// -// This function implements multiple validation checks to improve data quality: -// -// 1. **Coordinate Validation**: Ensures latitude/longitude are within Earth's bounds -// 2. **Altitude Validation**: Rejects impossible altitudes (negative or > FL600) -// 3. **Speed Validation**: Calculates implied speed and rejects >Mach 3 movements -// 4. **Distance Validation**: Rejects position jumps >500nm without time justification -// 5. **Time Validation**: Ensures timestamps are chronologically consistent -// -// Parameters: -// - aircraft: New aircraft position data to validate -// - state: Current aircraft state with position history -// - timestamp: Timestamp of the new position data -// -// Returns: -// - ValidationResult with valid flag and detailed error/warning messages -func (m *Merger) validatePosition(aircraft *modes.Aircraft, state *AircraftState, timestamp time.Time) *ValidationResult { - result := &ValidationResult{ - Valid: true, - Errors: make([]string, 0), - Warnings: make([]string, 0), - } - - // Skip validation if no position data - if aircraft.Latitude == 0 && aircraft.Longitude == 0 { - return result // No position to validate - } - - // 1. Geographic coordinate validation - if aircraft.Latitude < MinLatitude || aircraft.Latitude > MaxLatitude { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Invalid latitude: %.6f (must be between %.1f and %.1f)", - aircraft.Latitude, MinLatitude, MaxLatitude)) - } - - if aircraft.Longitude < MinLongitude || aircraft.Longitude > MaxLongitude { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Invalid longitude: %.6f (must be between %.1f and %.1f)", - aircraft.Longitude, MinLongitude, MaxLongitude)) - } - - // 2. Altitude validation - if aircraft.Altitude != 0 { // Only validate non-zero altitudes - if aircraft.Altitude < MinAltitudeFeet { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Impossible altitude: %d feet (below minimum %d)", - aircraft.Altitude, MinAltitudeFeet)) - } - - if aircraft.Altitude > MaxAltitudeFeet { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Impossible altitude: %d feet (above maximum %d)", - aircraft.Altitude, MaxAltitudeFeet)) - } - } - - // 3. Speed and distance validation (requires position history) - if len(state.PositionHistory) > 0 && state.Latitude != 0 && state.Longitude != 0 { - lastPos := state.PositionHistory[len(state.PositionHistory)-1] - - // Calculate distance between positions - distance, _ := calculateDistanceBearing(lastPos.Latitude, lastPos.Longitude, - aircraft.Latitude, aircraft.Longitude) - - // Calculate time difference - timeDiff := timestamp.Sub(lastPos.Time).Seconds() - - if timeDiff > 0 { - // Calculate implied speed in knots - distanceNm := distance / NmToKm - speedKnots := (distanceNm / timeDiff) * 3600 // Convert to knots per hour - - // Distance validation: reject jumps >500nm - if distanceNm > MaxDistanceNautMiles { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Impossible position jump: %.1f nm in %.1f seconds (max allowed: %.1f nm)", - distanceNm, timeDiff, MaxDistanceNautMiles)) - } - - // Speed validation: reject >2000 knots (roughly Mach 3) - if speedKnots > MaxSpeedKnots { - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Impossible speed: %.0f knots (max allowed: %.0f knots)", - speedKnots, MaxSpeedKnots)) - } - - // Warning for high but possible speeds (>800 knots) - if speedKnots > 800 && speedKnots <= MaxSpeedKnots { - result.Warnings = append(result.Warnings, fmt.Sprintf("High speed detected: %.0f knots", speedKnots)) - } - } else if timeDiff < 0 { - // 4. Time validation: reject out-of-order timestamps - result.Valid = false - result.Errors = append(result.Errors, fmt.Sprintf("Out-of-order timestamp: %.1f seconds in the past", -timeDiff)) - } - } - - // 5. Aircraft-specific validations based on reported speed vs. position - if aircraft.GroundSpeed > 0 && len(state.PositionHistory) > 0 { - // Check if reported ground speed is consistent with position changes - lastPos := state.PositionHistory[len(state.PositionHistory)-1] - distance, _ := calculateDistanceBearing(lastPos.Latitude, lastPos.Longitude, - aircraft.Latitude, aircraft.Longitude) - timeDiff := timestamp.Sub(lastPos.Time).Seconds() - - if timeDiff > 0 { - distanceNm := distance / NmToKm - impliedSpeed := (distanceNm / timeDiff) * 3600 - reportedSpeed := float64(aircraft.GroundSpeed) - - // Warning if speeds differ significantly (>100 knots difference) - if math.Abs(impliedSpeed-reportedSpeed) > 100 && reportedSpeed > 50 { - result.Warnings = append(result.Warnings, - fmt.Sprintf("Speed inconsistency: reported %d knots, implied %.0f knots", - aircraft.GroundSpeed, impliedSpeed)) - } - } - } - - return result -} - -// Close closes the merger and releases resources -func (m *Merger) Close() error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.icaoDB != nil { - return m.icaoDB.Close() - } - return nil -} diff --git a/internal/modes/decoder.go b/internal/modes/decoder.go deleted file mode 100644 index 4dd74cb..0000000 --- a/internal/modes/decoder.go +++ /dev/null @@ -1,1150 +0,0 @@ -// Package modes provides Mode S and ADS-B message decoding capabilities. -// -// Mode S is a secondary surveillance radar system that enables aircraft to transmit -// detailed information including position, altitude, velocity, and identification. -// ADS-B (Automatic Dependent Surveillance-Broadcast) is a modernization of Mode S -// that provides more precise and frequent position reports. -// -// This package implements: -// - Complete Mode S message decoding for all downlink formats -// - ADS-B extended squitter message parsing (DF17/18) -// - CPR (Compact Position Reporting) position decoding algorithm -// - Aircraft identification, category, and status decoding -// - Velocity and heading calculation from velocity messages -// - Navigation accuracy and integrity decoding -// -// Key Features: -// - CPR Global Position Decoding: Resolves ambiguous encoded positions using -// even/odd message pairs and trigonometric calculations -// - Multi-format Support: Handles surveillance replies, extended squitter, -// and various ADS-B message types -// - Real-time Processing: Maintains state for CPR decoding across messages -// - Comprehensive Data Extraction: Extracts all available aircraft parameters -// -// CPR Algorithm: -// The Compact Position Reporting format encodes latitude and longitude using -// two alternating formats (even/odd) that create overlapping grids. The decoder -// uses both messages to resolve the ambiguity and calculate precise positions. -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 ( - DF0 = 0 // Short air-air surveillance (ACAS) - DF4 = 4 // Surveillance altitude reply (interrogation response) - DF5 = 5 // Surveillance identity reply (squawk code response) - DF11 = 11 // All-call reply (capability and ICAO address) - DF16 = 16 // Long air-air surveillance (ACAS with altitude) - DF17 = 17 // Extended squitter (ADS-B from transponder) - DF18 = 18 // Extended squitter/non-transponder (ADS-B from other sources) - DF19 = 19 // Military extended squitter - DF20 = 20 // Comm-B altitude reply (BDS register data) - DF21 = 21 // Comm-B identity reply (BDS register data) - DF24 = 24 // Comm-D (ELM - Enhanced Length Message) -) - -// ADS-B Type Code (TC) constants for DF17/18 extended squitter messages. -// The type code (bits 32-36) determines the content and format of ADS-B messages. -const ( - TC_IDENT_CATEGORY = 1 // Aircraft identification and category (callsign) - TC_SURFACE_POS = 5 // Surface position (airport ground movement) - TC_AIRBORNE_POS_9 = 9 // Airborne position (barometric altitude) - TC_AIRBORNE_POS_20 = 20 // Airborne position (GNSS height above ellipsoid) - TC_AIRBORNE_VEL = 19 // Airborne velocity (ground speed and track) - TC_AIRBORNE_POS_GPS = 22 // Airborne position (GNSS altitude) - TC_RESERVED = 23 // Reserved for future use - TC_SURFACE_SYSTEM = 24 // Surface system status - TC_OPERATIONAL = 31 // Aircraft operational status (capabilities) -) - -// Aircraft represents a complete set of decoded aircraft data from Mode S/ADS-B messages. -// -// This structure contains all possible information that can be extracted from -// various Mode S and ADS-B message types, including position, velocity, status, -// and navigation data. Not all fields will be populated for every aircraft, -// depending on the messages received and aircraft capabilities. -type Aircraft struct { - // Core Identification - ICAO24 uint32 // 24-bit ICAO aircraft address (unique identifier) - Callsign string // 8-character flight callsign (from identification messages) - - // Position and Navigation - Latitude float64 // Position latitude in decimal degrees - Longitude float64 // Position longitude in decimal degrees - Altitude int // Altitude in feet (barometric or geometric) - BaroAltitude int // Barometric altitude in feet (QNH corrected) - GeomAltitude int // Geometric altitude in feet (GNSS height) - - // Motion and Dynamics - VerticalRate int // Vertical rate in feet per minute (climb/descent) - GroundSpeed int // Ground speed in knots (integer) - Track int // Track angle in degrees (0-359, integer) - Heading int // Aircraft heading in degrees (magnetic, integer) - - // Aircraft Information - Category string // Aircraft category (size, type, performance) - Squawk string // 4-digit transponder squawk code (octal) - - // Status and Alerts - Emergency string // Emergency/priority status description - OnGround bool // Aircraft is on ground (surface movement) - Alert bool // Alert flag (ATC attention required) - SPI bool // Special Position Identification (pilot activated) - - // Data Quality Indicators - NACp uint8 // Navigation Accuracy Category - Position (0-11) - NACv uint8 // Navigation Accuracy Category - Velocity (0-4) - SIL uint8 // Surveillance Integrity Level (0-3) - - // Transponder Information - TransponderCapability string // Transponder capability level (from DF11 messages) - TransponderLevel uint8 // Transponder level (0-7 from capability field) - - // Combined Data Quality Assessment - SignalQuality string // Combined assessment of position/velocity accuracy and integrity - - // Autopilot/Flight Management - SelectedAltitude int // MCP/FCU selected altitude in feet - SelectedHeading float64 // MCP/FCU selected heading in degrees - BaroSetting float64 // Barometric pressure setting (QNH) in millibars -} - -// Decoder handles Mode S and ADS-B message decoding with CPR position resolution. -// -// The decoder maintains state for CPR (Compact Position Reporting) decoding, -// which requires pairs of even/odd messages to resolve position ambiguity. -// Each aircraft (identified by ICAO24) has separate CPR state tracking. -// -// CPR Position Decoding: -// Aircraft positions are encoded using two alternating formats that create -// overlapping latitude/longitude grids. The decoder stores both even and odd -// encoded positions and uses trigonometric calculations to resolve the -// actual aircraft position when both are available. -type Decoder struct { - // CPR (Compact Position Reporting) state tracking per aircraft - cprEvenLat map[uint32]float64 // Even message latitude encoding (ICAO24 -> normalized lat) - cprEvenLon map[uint32]float64 // Even message longitude encoding (ICAO24 -> normalized lon) - cprOddLat map[uint32]float64 // Odd message latitude encoding (ICAO24 -> normalized lat) - 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) - - // Reference position for CPR zone ambiguity resolution (receiver location) - refLatitude float64 // Receiver latitude in decimal degrees - refLongitude float64 // Receiver longitude in decimal degrees - - // Mutex to protect concurrent access to CPR maps - mu sync.RWMutex -} - -// NewDecoder creates a new Mode S/ADS-B decoder with initialized CPR tracking. -// -// The reference position (typically the receiver location) is used to resolve -// CPR zone ambiguity during position decoding. Without a proper reference, -// aircraft can appear many degrees away from their actual position. -// -// Parameters: -// - refLat: Reference latitude in decimal degrees (receiver location) -// - refLon: Reference longitude in decimal degrees (receiver location) -// -// Returns a configured decoder ready for message processing. -func NewDecoder(refLat, refLon float64) *Decoder { - return &Decoder{ - cprEvenLat: make(map[uint32]float64), - cprEvenLon: make(map[uint32]float64), - cprOddLat: make(map[uint32]float64), - cprOddLon: make(map[uint32]float64), - cprEvenTime: make(map[uint32]int64), - cprOddTime: make(map[uint32]int64), - refLatitude: refLat, - refLongitude: refLon, - } -} - -// Decode processes a Mode S message and extracts all available aircraft information. -// -// This is the main entry point for message decoding. The method: -// 1. Validates message length and extracts the Downlink Format (DF) -// 2. Extracts the ICAO24 aircraft address -// 3. Routes to appropriate decoder based on message type -// 4. Returns populated Aircraft struct with available data -// -// Different message types provide different information: -// - DF4/20: Altitude only -// - DF5/21: Squawk code only -// - DF17/18: Complete ADS-B data (position, velocity, identification, etc.) -// -// Parameters: -// - data: Raw Mode S message bytes (7 or 14 bytes depending on type) -// -// Returns decoded Aircraft struct or error for invalid/incomplete messages. -func (d *Decoder) Decode(data []byte) (*Aircraft, error) { - if len(data) < 7 { - 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) - - - aircraft := &Aircraft{ - ICAO24: icao, - } - - switch df { - case DF0: - // Short Air-Air Surveillance (ACAS) - aircraft.Altitude = d.decodeAltitude(data) - case DF4, DF20: - aircraft.Altitude = d.decodeAltitude(data) - case DF5, DF21: - aircraft.Squawk = d.decodeSquawk(data) - case DF11: - // All-Call Reply - extract capability and interrogator identifier - d.decodeAllCallReply(data, aircraft) - case DF16: - // Long Air-Air Surveillance (ACAS with altitude) - aircraft.Altitude = d.decodeAltitude(data) - case DF17, DF18: - return d.decodeExtendedSquitter(data, aircraft) - case DF19: - // Military Extended Squitter - similar to DF17/18 but with military codes - return d.decodeMilitaryExtendedSquitter(data, aircraft) - case DF24: - // Comm-D Enhanced Length Message - variable length data - d.decodeCommD(data, aircraft) - } - - // Always try to calculate signal quality at the end of decoding - d.calculateSignalQuality(aircraft) - - return aircraft, nil -} - -// extractICAO extracts the ICAO 24-bit aircraft address from Mode S messages. -// -// For most downlink formats, the ICAO address is located in bytes 1-3 of the -// message. Some formats may have different layouts, but this implementation -// uses the standard position for all supported formats. -// -// Parameters: -// - data: Mode S message bytes -// - df: Downlink format (currently unused, but available for format-specific handling) -// -// Returns the 24-bit ICAO address as a uint32. -func (d *Decoder) extractICAO(data []byte, df uint8) uint32 { - // For most formats, ICAO is in bytes 1-3 - return uint32(data[1])<<16 | uint32(data[2])<<8 | uint32(data[3]) -} - -// decodeExtendedSquitter processes ADS-B extended squitter messages (DF17/18). -// -// Extended squitter messages contain the richest aircraft data, including: -// - Aircraft identification and category (TC 1-4) -// - Surface position and movement (TC 5-8) -// - Airborne position with various altitude sources (TC 9-18, 20-22) -// - Velocity and heading information (TC 19) -// - Aircraft status and emergency codes (TC 28) -// - Target state and autopilot settings (TC 29) -// - Operational status and navigation accuracy (TC 31) -// -// The method routes messages to specific decoders based on the Type Code (TC) -// field in bits 32-36 of the message. -// -// Parameters: -// - data: 14-byte extended squitter message -// - aircraft: Aircraft struct to populate with decoded data -// -// Returns the updated Aircraft struct or an error for malformed messages. -func (d *Decoder) decodeExtendedSquitter(data []byte, aircraft *Aircraft) (*Aircraft, error) { - if len(data) < 14 { - return nil, fmt.Errorf("extended squitter too short: %d bytes", len(data)) - } - - tc := (data[4] >> 3) & 0x1F - - switch { - case tc >= 1 && tc <= 4: - // Aircraft identification - d.decodeIdentification(data, aircraft) - case tc >= 5 && tc <= 8: - // Surface position - d.decodeSurfacePosition(data, aircraft) - case tc >= 9 && tc <= 18: - // Airborne position - d.decodeAirbornePosition(data, aircraft) - case tc == 19: - // Airborne velocity - d.decodeVelocity(data, aircraft) - case tc >= 20 && tc <= 22: - // Airborne position with GNSS - d.decodeAirbornePosition(data, aircraft) - case tc == 28: - // Aircraft status - d.decodeStatus(data, aircraft) - case tc == 29: - // Target state and status - d.decodeTargetState(data, aircraft) - case tc == 31: - // Operational status - d.decodeOperationalStatus(data, aircraft) - } - - // 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) - - return aircraft, nil -} - -// decodeIdentification extracts aircraft callsign and category from identification messages. -// -// Aircraft identification messages (TC 1-4) contain: -// - 8-character callsign encoded in 6-bit characters -// - Aircraft category indicating size, performance, and type -// -// Callsign Encoding: -// Each character is encoded in 6 bits using a custom character set: -// - Characters: "#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######" -// - Index 0-63 maps to the character at that position -// - '#' represents space or invalid characters -// -// Parameters: -// - data: Extended squitter message containing identification data -// - aircraft: Aircraft struct to update with callsign and category -func (d *Decoder) decodeIdentification(data []byte, aircraft *Aircraft) { - tc := (data[4] >> 3) & 0x1F - - // Category - aircraft.Category = d.getAircraftCategory(tc, data[4]&0x07) - - // Callsign - 8 characters encoded in 6 bits each - chars := "#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######" - callsign := "" - - // Extract 48 bits starting from bit 40 - for i := 0; i < 8; i++ { - bitOffset := 40 + i*6 - byteOffset := bitOffset / 8 - bitShift := bitOffset % 8 - - var charCode uint8 - if bitShift <= 2 { - charCode = (data[byteOffset] >> (2 - bitShift)) & 0x3F - } else { - charCode = ((data[byteOffset] << (bitShift - 2)) & 0x3F) | - (data[byteOffset+1] >> (10 - bitShift)) - } - - if charCode < 64 { - callsign += string(chars[charCode]) - } - } - - aircraft.Callsign = callsign -} - -// decodeAirbornePosition extracts aircraft position from CPR-encoded position messages. -// -// Airborne position messages (TC 9-18, 20-22) contain: -// - Altitude information (barometric or geometric) -// - CPR-encoded latitude and longitude -// - Even/odd flag for CPR decoding -// -// CPR (Compact Position Reporting) Process: -// 1. Extract the even/odd flag and CPR lat/lon values -// 2. Normalize CPR values to 0-1 range (divide by 2^17) -// 3. Store values for this aircraft's ICAO address -// 4. Attempt position decoding if both even and odd messages are available -// -// The actual position calculation requires both even and odd messages to -// resolve the ambiguity inherent in the compressed encoding format. -// -// Parameters: -// - data: Extended squitter message containing position data -// - aircraft: Aircraft struct to update with position and altitude -func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) { - tc := (data[4] >> 3) & 0x1F - - // Altitude - altBits := (uint16(data[5])<<4 | uint16(data[6])>>4) & 0x0FFF - aircraft.Altitude = d.decodeAltitudeBits(altBits, tc) - - // CPR latitude/longitude - cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1 - 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 - } else { - d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 - d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 - } - d.mu.Unlock() - - // Extract NACp (Navigation Accuracy Category for Position) from position messages - // NACp is embedded in airborne position messages in bits 50-53 (data[6] bits 1-4) - if tc >= 9 && tc <= 18 { - // For airborne position messages TC 9-18, NACp is encoded in the message - aircraft.NACp = uint8(tc - 8) // TC 9->NACp 1, TC 10->NACp 2, etc. - // Note: This is a simplified mapping. Real NACp extraction is more complex - // but this provides useful position accuracy indication - } - - // 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) -} - -// decodeCPRPosition performs CPR (Compact Position Reporting) global position decoding. -// -// CRITICAL: The CPR algorithm has zone ambiguity that requires either: -// 1. A reference position (receiver location) to resolve zones correctly, OR -// 2. Message timestamp comparison to choose the most recent valid position -// -// Without proper zone resolution, aircraft can appear 6+ degrees away from actual position. -// This implementation uses global decoding which can produce large errors without -// additional context about expected aircraft location. -// -// 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() - - // CPR input values ready for decoding - - // CPR decoding algorithm - dLat := 360.0 / 60.0 - j := math.Floor(evenLat*59 - oddLat*60 + 0.5) - - latEven := dLat * (math.Mod(j, 60) + evenLat) - latOdd := dLat * (math.Mod(j, 59) + oddLat) - - if latEven >= 270 { - latEven -= 360 - } - if latOdd >= 270 { - 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 - } - - // Zone ambiguity resolution using receiver reference position - // Calculate which decoded latitude is closer to the receiver - distToEven := math.Abs(latEven - d.refLatitude) - distToOdd := math.Abs(latOdd - d.refLatitude) - - // Choose the latitude solution that's closer to the receiver position - if distToOdd < distToEven { - aircraft.Latitude = latOdd - } else { - aircraft.Latitude = latEven - } - - // Longitude calculation - nl := d.nlFunction(aircraft.Latitude) - ni := math.Max(nl-1, 1) - dLon := 360.0 / ni - m := math.Floor(evenLon*(nl-1) - oddLon*nl + 0.5) - lon := dLon * (math.Mod(m, ni) + oddLon) - - 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 - - // CPR decoding completed successfully -} - -// nlFunction calculates the number of longitude zones (NL) for a given latitude. -// -// This function implements the NL(lat) calculation defined in the CPR specification. -// The number of longitude zones decreases as latitude approaches the poles due to -// the convergence of meridians. -// -// Mathematical Background: -// - At the equator: 60 longitude zones (6° each) -// - At higher latitudes: fewer zones as meridians converge -// - At poles (±87°): only 2 zones (180° each) -// -// Formula: NL(lat) = floor(2π / arccos(1 - (1-cos(π/(2*NZ))) / cos²(lat))) -// Where NZ = 15 (number of latitude zones) -// -// Parameters: -// - lat: Latitude in decimal degrees -// -// Returns the number of longitude zones for this latitude. -func (d *Decoder) nlFunction(lat float64) float64 { - if math.Abs(lat) >= 87 { - return 2 - } - - nz := 15.0 - a := 1 - math.Cos(math.Pi/(2*nz)) - b := math.Pow(math.Cos(math.Pi/180.0*math.Abs(lat)), 2) - nl := 2 * math.Pi / math.Acos(1-a/b) - - return math.Floor(nl) -} - -// decodeVelocity extracts ground speed, track, and vertical rate from velocity messages. -// -// Velocity messages (TC 19) contain: -// - Ground speed components (East-West and North-South) -// - Vertical rate (climb/descent rate) -// - Intent change flag and other status bits -// -// Ground Speed Calculation: -// - East-West and North-South velocity components are encoded separately -// - Each component has a direction bit and magnitude -// - Ground speed = sqrt(EW² + NS²) -// - Track angle = atan2(EW, NS) converted to degrees -// -// Vertical Rate: -// - Encoded in 64 ft/min increments with sign bit -// - Range: approximately ±32,000 ft/min -// -// Parameters: -// - data: Extended squitter message containing velocity data -// - aircraft: Aircraft struct to update with velocity information -func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) { - subtype := (data[4]) & 0x07 - - if subtype == 1 || subtype == 2 { - // Ground speed - ewRaw := uint16(data[5]&0x03)<<8 | uint16(data[6]) - nsRaw := uint16(data[7])<<3 | uint16(data[8])>>5 - - ewVel := float64(ewRaw - 1) - nsVel := float64(nsRaw - 1) - - if data[5]&0x04 != 0 { - ewVel = -ewVel - } - if data[7]&0x80 != 0 { - nsVel = -nsVel - } - - // 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) - trackDeg := math.Atan2(ewVel, nsVel) * 180 / math.Pi - if trackDeg < 0 { - trackDeg += 360 - } - aircraft.Track = int(math.Round(trackDeg)) - } - - // Vertical rate - vrSign := (data[8] >> 3) & 0x01 - vrBits := uint16(data[8]&0x07)<<6 | uint16(data[9])>>2 - if vrBits != 0 { - aircraft.VerticalRate = int(vrBits-1) * 64 - if vrSign != 0 { - aircraft.VerticalRate = -aircraft.VerticalRate - } - } -} - -// decodeAltitude extracts altitude from Mode S surveillance altitude replies. -// -// Mode S altitude replies (DF4, DF20) contain a 13-bit altitude code that -// must be converted from the transmitted encoding to actual altitude in feet. -// -// Parameters: -// - data: Mode S altitude reply message -// -// Returns altitude in feet above sea level. -func (d *Decoder) decodeAltitude(data []byte) int { - altCode := uint16(data[2])<<8 | uint16(data[3]) - return d.decodeAltitudeBits(altCode>>3, 0) -} - -// decodeAltitudeBits converts encoded altitude bits to altitude in feet. -// -// Altitude Encoding: -// - Uses modified Gray code for error resilience -// - 12-bit altitude code with 25-foot increments -// - Offset of -1000 feet (code 0 = -1000 ft) -// - Gray code conversion prevents single-bit errors from causing large altitude jumps -// -// Different altitude sources: -// - Standard: Barometric altitude (QNH corrected) -// - GNSS: Geometric altitude (height above WGS84 ellipsoid) -// -// Parameters: -// - altCode: 12-bit encoded altitude value -// - tc: Type code (determines altitude source interpretation) -// -// Returns altitude in feet, or 0 for invalid altitude codes. -func (d *Decoder) decodeAltitudeBits(altCode uint16, tc uint8) int { - if altCode == 0 { - return 0 - } - - // Standard altitude encoding with 25 ft increments - // Check Q-bit (bit 4) for encoding type - qBit := (altCode >> 4) & 1 - - if qBit == 1 { - // Standard altitude with Q-bit set - // Remove Q-bit and reassemble 11-bit altitude code - n := ((altCode & 0x1F80) >> 2) | ((altCode & 0x0020) >> 1) | (altCode & 0x000F) - alt := int(n)*25 - 1000 - - // Validate altitude range - if alt < -1000 || alt > 60000 { - return 0 - } - return alt - } - - // Gray code altitude (100 ft increments) - legacy encoding - // Convert from Gray code to binary - n := altCode - n ^= n >> 8 - n ^= n >> 4 - n ^= n >> 2 - n ^= n >> 1 - - // Convert to altitude in feet - alt := int(n&0x7FF) * 100 - if alt < 0 || alt > 60000 { - return 0 - } - return alt -} - -// decodeSquawk extracts the 4-digit squawk (transponder) code from identity replies. -// -// Squawk codes are 4-digit octal numbers (0000-7777) used by air traffic control -// for aircraft identification. They are transmitted in surveillance identity -// replies (DF5, DF21) and formatted as octal strings. -// -// Parameters: -// - data: Mode S identity reply message -// -// Returns 4-digit octal squawk code as a string (e.g., "1200", "7700"). -func (d *Decoder) decodeSquawk(data []byte) string { - code := uint16(data[2])<<8 | uint16(data[3]) - return fmt.Sprintf("%04o", code>>3) -} - -// getAircraftCategory converts type code and category fields to human-readable descriptions. -// -// Aircraft categories are encoded in identification messages using: -// - Type Code (TC): Broad category group (1-4) -// - Category (CA): Specific category within the group (0-7) -// -// Categories include: -// - TC 1: Reserved -// - TC 2: Surface vehicles (emergency, service, obstacles) -// - TC 3: Light aircraft (gliders, balloons, UAVs, etc.) -// - TC 4: Aircraft by weight class and performance -// -// These categories help ATC and other aircraft understand the type of vehicle -// and its performance characteristics for separation and routing. -// -// Parameters: -// - tc: Type code (1-4) from identification message -// - ca: Category field (0-7) providing specific subtype -// -// Returns human-readable category description. -func (d *Decoder) getAircraftCategory(tc uint8, ca uint8) string { - switch tc { - case 1: - return "Reserved" - case 2: - switch ca { - case 1: - return "Surface Emergency Vehicle" - case 3: - return "Surface Service Vehicle" - case 4, 5, 6, 7: - return "Ground Obstruction" - default: - return "Surface Vehicle" - } - case 3: - switch ca { - case 1: - return "Glider/Sailplane" - case 2: - return "Lighter-than-Air" - case 3: - return "Parachutist/Skydiver" - case 4: - return "Ultralight/Hang-glider" - case 6: - return "UAV" - case 7: - return "Space Vehicle" - default: - return "Light Aircraft" - } - case 4: - switch ca { - case 1: - return "Light < 7000kg" - case 2: - return "Medium 7000-34000kg" - case 3: - return "Large 34000-136000kg" - case 4: - return "High Vortex Large" - case 5: - return "Heavy > 136000kg" - case 6: - return "High Performance" - case 7: - return "Rotorcraft" - default: - return "Aircraft" - } - default: - return "Unknown" - } -} - -// decodeStatus extracts emergency and priority status from aircraft status messages. -// -// Aircraft status messages (TC 28) contain emergency and priority codes that -// indicate special situations requiring ATC attention: -// - General emergency (Mayday) -// - Medical emergency (Lifeguard) -// - Minimum fuel -// - Communication failure -// - Unlawful interference (hijack) -// - Downed aircraft -// -// These codes trigger special handling by ATC and emergency services. -// -// Parameters: -// - data: Extended squitter message containing status information -// - aircraft: Aircraft struct to update with emergency status -func (d *Decoder) decodeStatus(data []byte, aircraft *Aircraft) { - subtype := data[4] & 0x07 - - if subtype == 1 { - // Emergency/priority status - emergency := (data[5] >> 5) & 0x07 - switch emergency { - case 0: - aircraft.Emergency = "None" - case 1: - aircraft.Emergency = "General Emergency" - case 2: - aircraft.Emergency = "Lifeguard/Medical" - case 3: - aircraft.Emergency = "Minimum Fuel" - case 4: - aircraft.Emergency = "No Communications" - case 5: - aircraft.Emergency = "Unlawful Interference" - case 6: - aircraft.Emergency = "Downed Aircraft" - } - } -} - -// decodeTargetState extracts autopilot and flight management system settings. -// -// Target state and status messages (TC 29) contain information about: -// - Selected altitude (MCP/FCU setting) -// - Barometric pressure setting (QNH) -// - Autopilot engagement status -// - Flight management system intentions -// -// This information helps ATC understand pilot intentions and autopilot settings, -// improving situational awareness and conflict prediction. -// -// Parameters: -// - data: Extended squitter message containing target state data -// - aircraft: Aircraft struct to update with autopilot settings -func (d *Decoder) decodeTargetState(data []byte, aircraft *Aircraft) { - // Selected altitude - altBits := uint16(data[5]&0x7F)<<4 | uint16(data[6])>>4 - if altBits != 0 { - aircraft.SelectedAltitude = int(altBits)*32 - 32 - } - - // Barometric pressure setting - baroBits := uint16(data[7])<<1 | uint16(data[8])>>7 - if baroBits != 0 { - aircraft.BaroSetting = float64(baroBits)*0.8 + 800 - } -} - -// decodeOperationalStatus extracts navigation accuracy and system capability information. -// -// Operational status messages (TC 31) contain: -// - Navigation Accuracy Category for Position (NACp): Position accuracy -// - Navigation Accuracy Category for Velocity (NACv): Velocity accuracy -// - Surveillance Integrity Level (SIL): System integrity confidence -// -// These parameters help receiving systems assess data quality and determine -// appropriate separation standards for the aircraft. -// -// Parameters: -// - data: Extended squitter message containing operational status -// - aircraft: Aircraft struct to update with accuracy indicators -func (d *Decoder) decodeOperationalStatus(data []byte, aircraft *Aircraft) { - // Navigation accuracy categories - aircraft.NACp = (data[7] >> 4) & 0x0F - aircraft.NACv = data[7] & 0x0F - aircraft.SIL = (data[8] >> 6) & 0x03 - - // Calculate combined signal quality from NACp, NACv, and SIL - d.calculateSignalQuality(aircraft) -} - -// decodeSurfacePosition extracts position and movement data for aircraft on the ground. -// -// Surface position messages (TC 5-8) are used for airport ground movement tracking: -// - Ground speed and movement direction -// - Track angle (direction of movement) -// - CPR-encoded position (same algorithm as airborne) -// - On-ground flag is automatically set -// -// Ground Movement Encoding: -// - Speed ranges from stationary to 175+ knots in non-linear increments -// - Track is encoded in 128 discrete directions (2.8125° resolution) -// - Position uses the same CPR encoding as airborne messages -// -// Parameters: -// - data: Extended squitter message containing surface position data -// - aircraft: Aircraft struct to update with ground movement information -func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) { - aircraft.OnGround = true - - // Movement - movement := uint8(data[4]&0x07)<<4 | uint8(data[5])>>4 - if movement > 0 && movement < 125 { - aircraft.GroundSpeed = int(math.Round(d.decodeGroundSpeed(movement))) - } - - // Track - trackValid := (data[5] >> 3) & 0x01 - if trackValid != 0 { - trackBits := uint16(data[5]&0x07)<<4 | uint16(data[6])>>4 - aircraft.Track = int(math.Round(float64(trackBits) * 360.0 / 128.0)) - } - - // CPR position (similar to airborne) - cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1 - 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 - } else { - d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0 - d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0 - } - d.mu.Unlock() - - d.decodeCPRPosition(aircraft) -} - -// decodeGroundSpeed converts the surface movement field to ground speed in knots. -// -// Surface movement is encoded in non-linear ranges optimized for typical -// ground operations: -// - 0: No movement information -// - 1: Stationary -// - 2-8: 0.125-1.0 kt (fine resolution for slow movement) -// - 9-12: 1.0-2.0 kt (taxi speeds) -// - 13-38: 2.0-15.0 kt (normal taxi) -// - 39-93: 15.0-70.0 kt (high speed taxi/runway) -// - 94-108: 70.0-100.0 kt (takeoff/landing roll) -// - 109-123: 100.0-175.0 kt (high speed operations) -// - 124: >175 kt -// -// Parameters: -// - movement: 7-bit movement field from surface position message -// -// Returns ground speed in knots, or 0 for invalid/no movement. -func (d *Decoder) decodeGroundSpeed(movement uint8) float64 { - if movement == 1 { - return 0 - } else if movement >= 2 && movement <= 8 { - return float64(movement-2)*0.125 + 0.125 - } else if movement >= 9 && movement <= 12 { - return float64(movement-9)*0.25 + 1.0 - } else if movement >= 13 && movement <= 38 { - return float64(movement-13)*0.5 + 2.0 - } else if movement >= 39 && movement <= 93 { - return float64(movement-39)*1.0 + 15.0 - } else if movement >= 94 && movement <= 108 { - return float64(movement-94)*2.0 + 70.0 - } else if movement >= 109 && movement <= 123 { - return float64(movement-109)*5.0 + 100.0 - } else if movement == 124 { - return 175.0 - } - return 0 -} - -// decodeAllCallReply extracts capability and interrogator identifier from DF11 messages. -// -// DF11 All-Call Reply messages contain: -// - Capability (CA) field (3 bits): transponder capabilities and modes -// - Interrogator Identifier (II) field (4 bits): which radar interrogated -// - ICAO24 address (24 bits): aircraft identifier -// -// The capability field indicates transponder features and operational modes: -// - 0: Level 1 transponder -// - 1: Level 2 transponder -// - 2: Level 2+ transponder with additional capabilities -// - 3: Level 2+ transponder with enhanced surveillance -// - 4: Level 2+ transponder with enhanced surveillance and extended squitter -// - 5: Level 2+ transponder with enhanced surveillance, extended squitter, and enhanced surveillance capability -// - 6: Level 2+ transponder with enhanced surveillance, extended squitter, and enhanced surveillance capability -// - 7: Level 2+ transponder, downlink request value is 0, or the flight status is alert, SPI, or emergency -// -// Parameters: -// - data: 7-byte DF11 message -// - aircraft: Aircraft struct to populate -func (d *Decoder) decodeAllCallReply(data []byte, aircraft *Aircraft) { - if len(data) < 7 { - return - } - - // Extract Capability (CA) - bits 6-8 of first byte - capability := (data[0] >> 0) & 0x07 - - // Extract Interrogator Identifier (II) - would be in control field if present - // For DF11, this information is typically implied by the interrogating radar - - // Store transponder capability information in dedicated fields - aircraft.TransponderLevel = capability - switch capability { - case 0: - aircraft.TransponderCapability = "Level 1" - case 1: - aircraft.TransponderCapability = "Level 2" - case 2, 3: - aircraft.TransponderCapability = "Level 2+" - case 4, 5, 6: - aircraft.TransponderCapability = "Enhanced" - case 7: - aircraft.TransponderCapability = "Alert/Emergency" - } -} - -// decodeMilitaryExtendedSquitter processes DF19 military extended squitter messages. -// -// DF19 messages have the same structure as DF17/18 ADS-B extended squitter but -// may contain military-specific type codes or enhanced data formats. -// This implementation treats them similarly to civilian extended squitter -// but could be extended for military-specific capabilities. -// -// Parameters: -// - data: 14-byte DF19 message -// - aircraft: Aircraft struct to populate -// -// Returns updated Aircraft struct or error for malformed messages. -func (d *Decoder) decodeMilitaryExtendedSquitter(data []byte, aircraft *Aircraft) (*Aircraft, error) { - if len(data) != 14 { - return nil, fmt.Errorf("invalid military extended squitter length: %d bytes", len(data)) - } - - // For now, treat military extended squitter similar to civilian - // Could be enhanced to handle military-specific type codes - return d.decodeExtendedSquitter(data, aircraft) -} - -// decodeCommD extracts data from DF24 Comm-D Enhanced Length Messages. -// -// DF24 messages are variable-length data link communications that can contain: -// - Weather information and updates -// - Flight plan modifications -// - Controller-pilot data link messages -// - Air traffic management information -// - Future air navigation system data -// -// Due to the complexity and variety of DF24 message content, this implementation -// provides basic structure extraction. Full decoding would require extensive -// knowledge of specific data link protocols and message formats. -// -// Parameters: -// - data: Variable-length DF24 message (minimum 7 bytes) -// - aircraft: Aircraft struct to populate -func (d *Decoder) decodeCommD(data []byte, aircraft *Aircraft) { - if len(data) < 7 { - return - } - - // DF24 messages contain variable data that would require protocol-specific decoding - // For now, we note that this is a data communication message but don't overwrite aircraft category - // Could set a separate field for message type if needed in the future - - // The actual message content would require: - // - Protocol identifier extraction - // - Message type determination - // - Format-specific field extraction - // - Possible message reassembly for multi-part messages - // - // This could be extended based on specific requirements and available documentation -} - -// calculateSignalQuality combines NACp, NACv, and SIL into an overall data quality assessment. -// -// This function provides a human-readable quality indicator that considers: -// - Position accuracy (NACp): How precise the aircraft's position data is -// - Velocity accuracy (NACv): How precise the speed/heading data is -// - Surveillance integrity (SIL): How reliable/trustworthy the data is -// -// The algorithm prioritizes integrity first (SIL), then position accuracy (NACp), -// then velocity accuracy (NACv) to provide a meaningful overall assessment. -// -// Quality levels: -// - "Excellent": High integrity with very precise position/velocity -// - "Good": Good integrity with reasonable precision -// - "Fair": Moderate quality suitable for tracking -// - "Poor": Low quality but still usable -// - "Unknown": No quality indicators available -// -// Parameters: -// - aircraft: Aircraft struct containing NACp, NACv, and SIL values -func (d *Decoder) calculateSignalQuality(aircraft *Aircraft) { - nacp := aircraft.NACp - nacv := aircraft.NACv - sil := aircraft.SIL - - // If no quality indicators are available, don't set anything - if nacp == 0 && nacv == 0 && sil == 0 { - // Don't overwrite existing quality assessment - return - } - - // Excellent: High integrity with high accuracy OR very high accuracy alone - if (sil >= 2 && nacp >= 9) || nacp >= 10 { - aircraft.SignalQuality = "Excellent" - return - } - - // Good: Good integrity with moderate accuracy OR high accuracy alone - if (sil >= 2 && nacp >= 6) || (sil >= 1 && nacp >= 9) || nacp >= 8 { - aircraft.SignalQuality = "Good" - return - } - - // Fair: Some integrity with basic accuracy OR moderate accuracy alone - if (sil >= 1 && nacp >= 3) || nacp >= 5 { - aircraft.SignalQuality = "Fair" - return - } - - // Poor: Low but usable quality indicators - if sil > 0 || nacp >= 1 || nacv > 0 { - aircraft.SignalQuality = "Poor" - return - } - - // Default fallback - aircraft.SignalQuality = "" -} diff --git a/internal/parser/sbs1.go b/internal/parser/sbs1.go new file mode 100644 index 0000000..8106988 --- /dev/null +++ b/internal/parser/sbs1.go @@ -0,0 +1,225 @@ +package parser + +import ( + "strconv" + "strings" + "time" +) + +type TrackPoint struct { + Timestamp time.Time `json:"timestamp"` + Latitude float64 `json:"lat"` + Longitude float64 `json:"lon"` + Altitude int `json:"altitude"` + Speed int `json:"speed"` + Track int `json:"track"` +} + +type Aircraft struct { + Hex string `json:"hex"` + Flight string `json:"flight,omitempty"` + Altitude int `json:"alt_baro,omitempty"` + GroundSpeed int `json:"gs,omitempty"` + Track int `json:"track,omitempty"` + Latitude float64 `json:"lat,omitempty"` + Longitude float64 `json:"lon,omitempty"` + VertRate int `json:"vert_rate,omitempty"` + Squawk string `json:"squawk,omitempty"` + Emergency bool `json:"emergency,omitempty"` + OnGround bool `json:"on_ground,omitempty"` + LastSeen time.Time `json:"last_seen"` + Messages int `json:"messages"` + TrackHistory []TrackPoint `json:"track_history,omitempty"` + RSSI float64 `json:"rssi,omitempty"` + Country string `json:"country,omitempty"` + Registration string `json:"registration,omitempty"` +} + +type AircraftData struct { + Now int64 `json:"now"` + Messages int `json:"messages"` + Aircraft map[string]Aircraft `json:"aircraft"` +} + +func ParseSBS1Line(line string) (*Aircraft, error) { + parts := strings.Split(strings.TrimSpace(line), ",") + if len(parts) < 22 { + return nil, nil + } + + // messageType := parts[1] + // Accept all message types to get complete data + // MSG types: 1=ES_IDENT_AND_CATEGORY, 2=ES_SURFACE_POS, 3=ES_AIRBORNE_POS + // 4=ES_AIRBORNE_VEL, 5=SURVEILLANCE_ALT, 6=SURVEILLANCE_ID, 7=AIR_TO_AIR, 8=ALL_CALL_REPLY + + aircraft := &Aircraft{ + Hex: strings.TrimSpace(parts[4]), + LastSeen: time.Now(), + Messages: 1, + } + + // Different message types contain different fields + // Always try to extract what's available + if parts[10] != "" { + aircraft.Flight = strings.TrimSpace(parts[10]) + } + + if parts[11] != "" { + if alt, err := strconv.Atoi(parts[11]); err == nil { + aircraft.Altitude = alt + } + } + + if parts[12] != "" { + if gs, err := strconv.Atoi(parts[12]); err == nil { + aircraft.GroundSpeed = gs + } + } + + if parts[13] != "" { + if track, err := strconv.ParseFloat(parts[13], 64); err == nil { + aircraft.Track = int(track) + } + } + + if parts[14] != "" && parts[15] != "" { + if lat, err := strconv.ParseFloat(parts[14], 64); err == nil { + aircraft.Latitude = lat + } + if lon, err := strconv.ParseFloat(parts[15], 64); err == nil { + aircraft.Longitude = lon + } + } + + if parts[16] != "" { + if vr, err := strconv.Atoi(parts[16]); err == nil { + aircraft.VertRate = vr + } + } + + if parts[17] != "" { + aircraft.Squawk = strings.TrimSpace(parts[17]) + } + + if parts[21] != "" { + aircraft.OnGround = parts[21] == "1" + } + + aircraft.Country = getCountryFromICAO(aircraft.Hex) + aircraft.Registration = getRegistrationFromICAO(aircraft.Hex) + + return aircraft, nil +} + +func getCountryFromICAO(icao string) string { + if len(icao) < 6 { + return "Unknown" + } + + prefix := icao[:1] + + switch prefix { + case "4": + return getCountryFrom4xxxx(icao) + case "A": + return "United States" + case "C": + return "Canada" + case "D": + return "Germany" + case "F": + return "France" + case "G": + return "United Kingdom" + case "I": + return "Italy" + case "J": + return "Japan" + case "P": + return getPCountry(icao) + case "S": + return getSCountry(icao) + case "O": + return getOCountry(icao) + default: + return "Unknown" + } +} + +func getCountryFrom4xxxx(icao string) string { + if len(icao) >= 2 { + switch icao[:2] { + case "40": + return "United Kingdom" + case "44": + return "Austria" + case "45": + return "Denmark" + case "46": + return "Germany" + case "47": + return "Germany" + case "48": + return "Netherlands" + case "49": + return "Netherlands" + } + } + return "Europe" +} + +func getPCountry(icao string) string { + if len(icao) >= 2 { + switch icao[:2] { + case "PH": + return "Netherlands" + case "PJ": + return "Netherlands Antilles" + } + } + return "Unknown" +} + +func getSCountry(icao string) string { + if len(icao) >= 2 { + switch icao[:2] { + case "SE": + return "Sweden" + case "SX": + return "Greece" + } + } + return "Unknown" +} + +func getOCountry(icao string) string { + if len(icao) >= 2 { + switch icao[:2] { + case "OO": + return "Belgium" + case "OH": + return "Finland" + } + } + return "Unknown" +} + +func getRegistrationFromICAO(icao string) string { + // This is a simplified conversion - real registration lookup would need a database + country := getCountryFromICAO(icao) + switch country { + case "Germany": + return "D-" + icao[2:] + case "United Kingdom": + return "G-" + icao[2:] + case "France": + return "F-" + icao[2:] + case "Netherlands": + return "PH-" + icao[2:] + case "Sweden": + return "SE-" + icao[2:] + default: + return icao + } +} + diff --git a/internal/server/server.go b/internal/server/server.go index 2197f85..0ba4f4c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,541 +1,187 @@ -// Package server provides HTTP and WebSocket services for the SkyView application. -// -// This package implements the web server that serves both static assets and real-time -// aircraft data via REST API endpoints and WebSocket connections. It handles: -// - Static web file serving from embedded assets -// - RESTful API endpoints for aircraft, sources, and statistics -// - Real-time WebSocket streaming for live aircraft updates -// - CORS handling for cross-origin requests -// - Coverage and heatmap data generation for visualization -// -// The server integrates with the merger component to access consolidated aircraft -// data from multiple sources and provides various data formats optimized for -// web consumption. package server import ( "context" "embed" "encoding/json" - "fmt" "log" + "mime" "net/http" "path" - "strconv" - "strings" "sync" - "time" "github.com/gorilla/mux" "github.com/gorilla/websocket" - "skyview/internal/merger" + "skyview/internal/client" + "skyview/internal/config" + "skyview/internal/parser" ) -// OriginConfig represents the geographical reference point configuration. -// This is used as the center point for the web map interface and for -// distance calculations in coverage analysis. -type OriginConfig struct { - Latitude float64 `json:"latitude"` // Reference latitude in decimal degrees - Longitude float64 `json:"longitude"` // Reference longitude in decimal degrees - Name string `json:"name,omitempty"` // Descriptive name for the origin point -} - -// Server handles HTTP requests and WebSocket connections for the SkyView web interface. -// It serves static web assets, provides RESTful API endpoints for aircraft data, -// and maintains real-time WebSocket connections for live updates. -// -// The server architecture uses: -// - Gorilla mux for HTTP routing -// - Gorilla WebSocket for real-time communication -// - Embedded filesystem for static asset serving -// - Concurrent broadcast system for WebSocket clients -// - CORS support for cross-origin web applications type Server struct { - host string // Bind address for HTTP server - port int // TCP port for HTTP server - merger *merger.Merger // Data source for aircraft information - staticFiles embed.FS // Embedded static web assets - server *http.Server // HTTP server instance - origin OriginConfig // Geographic reference point - - // WebSocket management - wsClients map[*websocket.Conn]bool // Active WebSocket client connections - wsClientsMu sync.RWMutex // Protects wsClients map - upgrader websocket.Upgrader // HTTP to WebSocket protocol upgrader - - // Broadcast channels for real-time updates - broadcastChan chan []byte // Channel for broadcasting updates to all clients - stopChan chan struct{} // Shutdown signal channel + config *config.Config + staticFiles embed.FS + upgrader websocket.Upgrader + wsClients map[*websocket.Conn]bool + wsClientsMux sync.RWMutex + dump1090 *client.Dump1090Client + ctx context.Context } -// WebSocketMessage represents the standard message format for WebSocket communication. -// All messages sent to clients follow this structure to provide consistent -// message handling and enable message type discrimination on the client side. type WebSocketMessage struct { - Type string `json:"type"` // Message type ("initial_data", "aircraft_update", etc.) - Timestamp int64 `json:"timestamp"` // Unix timestamp when message was created - Data interface{} `json:"data"` // Message payload (varies by type) + Type string `json:"type"` + Data interface{} `json:"data"` } -// AircraftUpdate represents the complete aircraft data payload sent via WebSocket. -// This structure contains all information needed by the web interface to display -// current aircraft positions, source status, and system statistics. -type AircraftUpdate struct { - Aircraft map[string]*merger.AircraftState `json:"aircraft"` // Current aircraft keyed by ICAO hex string - Sources []*merger.Source `json:"sources"` // Active data sources with status - Stats map[string]interface{} `json:"stats"` // System statistics and metrics -} - -// NewWebServer creates a new HTTP server instance for serving the SkyView web interface. -// -// The server is configured with: -// - WebSocket upgrader allowing all origins (suitable for development) -// - Buffered broadcast channel for efficient message distribution -// - Read/Write buffers optimized for aircraft data messages -// -// Parameters: -// - host: Bind address (empty for all interfaces, "localhost" for local only) -// - port: TCP port number for the HTTP server -// - merger: Data merger instance providing aircraft information -// - staticFiles: Embedded filesystem containing web assets -// - origin: Geographic reference point for the map interface -// -// Returns a configured but not yet started server instance. -func NewWebServer(host string, port int, merger *merger.Merger, staticFiles embed.FS, origin OriginConfig) *Server { - return &Server{ - host: host, - port: port, - merger: merger, +func New(cfg *config.Config, staticFiles embed.FS, ctx context.Context) http.Handler { + s := &Server{ + config: cfg, staticFiles: staticFiles, - origin: origin, - wsClients: make(map[*websocket.Conn]bool), upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins in development + return true }, - ReadBufferSize: 8192, - WriteBufferSize: 8192, }, - broadcastChan: make(chan []byte, 1000), - stopChan: make(chan struct{}), - } -} - -// Start begins serving HTTP requests and WebSocket connections. -// -// This method starts several background routines: -// 1. Broadcast routine - handles WebSocket message distribution -// 2. Periodic update routine - sends regular updates to WebSocket clients -// 3. HTTP server - serves API endpoints and static files -// -// The method blocks until the server encounters an error or is shut down. -// Use Stop() for graceful shutdown. -// -// Returns an error if the server fails to start or encounters a fatal error. -func (s *Server) Start() error { - // Start broadcast routine - go s.broadcastRoutine() - - // Start periodic updates - go s.periodicUpdateRoutine() - - // Setup routes - router := s.setupRoutes() - - // Format address correctly for IPv6 - addr := fmt.Sprintf("%s:%d", s.host, s.port) - if strings.Contains(s.host, ":") { - // IPv6 address needs brackets - addr = fmt.Sprintf("[%s]:%d", s.host, s.port) + wsClients: make(map[*websocket.Conn]bool), + dump1090: client.NewDump1090Client(cfg), + ctx: ctx, } - s.server = &http.Server{ - Addr: addr, - Handler: router, + if err := s.dump1090.Start(ctx); err != nil { + log.Printf("Failed to start dump1090 client: %v", err) } - return s.server.ListenAndServe() -} + go s.subscribeToAircraftUpdates() -// Stop gracefully shuts down the server and all background routines. -// -// This method: -// 1. Signals all background routines to stop via stopChan -// 2. Shuts down the HTTP server with a 5-second timeout -// 3. Closes WebSocket connections -// -// The shutdown is designed to be safe and allow in-flight requests to complete. -func (s *Server) Stop() { - close(s.stopChan) - - if s.server != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - s.server.Shutdown(ctx) - } -} - -// setupRoutes configures the HTTP routing for all server endpoints. -// -// The routing structure includes: -// - /api/* - RESTful API endpoints for data access -// - /ws - WebSocket endpoint for real-time updates -// - /static/* - Static file serving -// - / - Main application page -// -// All routes are wrapped with CORS middleware for cross-origin support. -// -// Returns a configured HTTP handler ready for use with the HTTP server. -func (s *Server) setupRoutes() http.Handler { router := mux.NewRouter() - // Health check endpoint for load balancers/monitoring - router.HandleFunc("/health", s.handleHealthCheck).Methods("GET") + router.HandleFunc("/", s.serveIndex).Methods("GET") + router.HandleFunc("/favicon.ico", s.serveFavicon).Methods("GET") + router.HandleFunc("/ws", s.handleWebSocket).Methods("GET") + + apiRouter := router.PathPrefix("/api").Subrouter() + apiRouter.HandleFunc("/aircraft", s.getAircraft).Methods("GET") + apiRouter.HandleFunc("/aircraft/{hex}/history", s.getAircraftHistory).Methods("GET") + apiRouter.HandleFunc("/stats", s.getStats).Methods("GET") + apiRouter.HandleFunc("/config", s.getConfig).Methods("GET") - // API routes - 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") - api.HandleFunc("/coverage/{sourceId}", s.handleGetCoverage).Methods("GET") - api.HandleFunc("/heatmap/{sourceId}", s.handleGetHeatmap).Methods("GET") + router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", s.staticFileHandler())) - // WebSocket - router.HandleFunc("/ws", s.handleWebSocket) - - // Static files - router.PathPrefix("/static/").Handler(s.staticFileHandler()) - router.HandleFunc("/favicon.ico", s.handleFavicon) - - // Main page - router.HandleFunc("/", s.handleIndex) - - // Enable CORS 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 -} - -// handleHealthCheck serves the /health endpoint for monitoring and load balancers. -// Returns a simple health status with basic service information. -// -// Response includes: -// - status: "healthy" or "degraded" -// - uptime: server uptime in seconds -// - sources: number of active sources and their connection status -// - aircraft: current aircraft count -// -// The endpoint returns: -// - 200 OK when the service is healthy -// - 503 Service Unavailable when the service is degraded (no active sources) -func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { - sources := s.merger.GetSources() - stats := s.merger.GetStatistics() - aircraft := s.merger.GetAircraft() - - // Check if we have any active sources - activeSources := 0 - for _, source := range sources { - if source.Active { - activeSources++ - } - } - - // Determine health status - status := "healthy" - statusCode := http.StatusOK - if activeSources == 0 && len(sources) > 0 { - status = "degraded" - statusCode = http.StatusServiceUnavailable - } - - response := map[string]interface{}{ - "status": status, - "timestamp": time.Now().Unix(), - "sources": map[string]interface{}{ - "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) -} - -// 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 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 and filter useful aircraft - aircraftMap := make(map[string]*merger.AircraftState) - for icao, state := range aircraft { - if s.isAircraftUseful(state) { - aircraftMap[fmt.Sprintf("%06X", icao)] = state - } - } - - response := map[string]interface{}{ - "timestamp": time.Now().Unix(), - "aircraft": aircraftMap, - "count": len(aircraftMap), // Count of filtered useful aircraft - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// handleGetAircraftDetails serves the /api/aircraft/{icao} endpoint. -// Returns detailed information for a specific aircraft identified by ICAO address. -// -// The ICAO parameter should be a 6-digit hexadecimal string (e.g., "ABC123"). -// Returns 400 Bad Request for invalid ICAO format. -// Returns 404 Not Found if the aircraft is not currently tracked. -// -// On success, returns the complete AircraftState for the requested aircraft. -func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - icaoStr := vars["icao"] - - // Parse ICAO hex string - icao, err := strconv.ParseUint(icaoStr, 16, 32) +func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) { + data, err := s.staticFiles.ReadFile("static/index.html") if err != nil { - http.Error(w, "Invalid ICAO address", http.StatusBadRequest) + http.Error(w, "Failed to read index.html", http.StatusInternalServerError) return } - aircraft := s.merger.GetAircraft() - if state, exists := aircraft[uint32(icao)]; exists { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(state) - } else { - http.Error(w, "Aircraft not found", http.StatusNotFound) - } + w.Header().Set("Content-Type", "text/html") + w.Write(data) } -// handleGetSources serves the /api/sources endpoint. -// Returns information about all configured data sources and their current status. -// -// The response includes: -// - sources: Array of source configurations with connection status -// - count: Total number of configured sources -// -// This endpoint is useful for monitoring source connectivity and debugging -// multi-source setups. -func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) { - sources := s.merger.GetSources() +func (s *Server) serveFavicon(w http.ResponseWriter, r *http.Request) { + data, err := s.staticFiles.ReadFile("static/favicon.ico") + if err != nil { + w.Header().Set("Content-Type", "image/x-icon") + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "image/x-icon") + w.Write(data) +} + +func (s *Server) getAircraft(w http.ResponseWriter, r *http.Request) { + data := s.dump1090.GetAircraftData() + + response := map[string]interface{}{ + "now": data.Now, + "messages": data.Messages, + "aircraft": s.aircraftMapToSlice(data.Aircraft), + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "sources": sources, - "count": len(sources), - }) + json.NewEncoder(w).Encode(response) } -// handleGetStats serves the /api/stats endpoint. -// Returns system statistics and performance metrics from the data merger. -// -// Statistics may include: -// - Message processing rates -// - Aircraft count by source -// - Connection status -// - Data quality metrics -// -// The exact statistics depend on the merger implementation. -func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { - stats := s.merger.GetStatistics() +func (s *Server) getStats(w http.ResponseWriter, r *http.Request) { + data := s.dump1090.GetAircraftData() + + stats := map[string]interface{}{ + "total": map[string]interface{}{ + "aircraft": len(data.Aircraft), + "messages": map[string]interface{}{ + "total": data.Messages, + "last1min": data.Messages, + }, + }, + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } -// handleGetOrigin serves the /api/origin endpoint. -// Returns the configured geographical reference point used by the system. -// -// The origin point is used for: -// - Default map center in the web interface -// - Distance calculations in coverage analysis -// - Range circle calculations -func (s *Server) handleGetOrigin(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(s.origin) -} - -// handleGetCoverage serves the /api/coverage/{sourceId} endpoint. -// Returns coverage data for a specific source based on aircraft positions and signal strength. -// -// The coverage data includes all positions where the specified source has received -// aircraft signals, along with signal strength and distance information. -// This is useful for visualizing receiver coverage patterns and range. -// -// Parameters: -// - sourceId: URL parameter identifying the source -// -// Returns array of coverage points with lat/lon, signal strength, distance, and altitude. -func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) { +func (s *Server) getAircraftHistory(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - sourceID := vars["sourceId"] + hex := vars["hex"] + + data := s.dump1090.GetAircraftData() + aircraft, exists := data.Aircraft[hex] + if !exists { + http.Error(w, "Aircraft not found", http.StatusNotFound) + return + } - // Generate coverage data based on signal strength - aircraft := s.merger.GetAircraft() - coveragePoints := make([]map[string]interface{}, 0) - - for _, state := range aircraft { - if srcData, exists := state.Sources[sourceID]; exists { - coveragePoints = append(coveragePoints, map[string]interface{}{ - "lat": state.Latitude, - "lon": state.Longitude, - "signal": srcData.SignalLevel, - "distance": srcData.Distance, - "altitude": state.Altitude, - }) - } + response := map[string]interface{}{ + "hex": aircraft.Hex, + "flight": aircraft.Flight, + "track_history": aircraft.TrackHistory, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "source": sourceID, - "points": coveragePoints, - }) + json.NewEncoder(w).Encode(response) } -// handleGetHeatmap serves the /api/heatmap/{sourceId} endpoint. -// Generates a grid-based heatmap visualization of signal coverage for a specific source. -// -// The heatmap is computed by: -// 1. Finding geographic bounds of all aircraft positions for the source -// 2. Creating a 100x100 grid covering the bounds -// 3. Accumulating signal strength values in each grid cell -// 4. Returning the grid data with boundary coordinates -// -// This provides a density-based visualization of where the source receives -// the strongest signals, useful for coverage analysis and antenna optimization. -// -// Parameters: -// - sourceId: URL parameter identifying the source -// -// Returns grid data array and geographic bounds for visualization. -func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - sourceID := vars["sourceId"] - - // Generate heatmap data grid - aircraft := s.merger.GetAircraft() - heatmapData := make(map[string]interface{}) - - // Simple grid-based heatmap - grid := make([][]float64, 100) - for i := range grid { - grid[i] = make([]float64, 100) - } - - // Find bounds - minLat, maxLat := 90.0, -90.0 - minLon, maxLon := 180.0, -180.0 - - for _, state := range aircraft { - if _, exists := state.Sources[sourceID]; exists { - if state.Latitude < minLat { - minLat = state.Latitude - } - if state.Latitude > maxLat { - maxLat = state.Latitude - } - if state.Longitude < minLon { - minLon = state.Longitude - } - if state.Longitude > maxLon { - maxLon = state.Longitude - } - } - } - - // Fill grid - for _, state := range aircraft { - if srcData, exists := state.Sources[sourceID]; exists { - latIdx := int((state.Latitude - minLat) / (maxLat - minLat) * 99) - lonIdx := int((state.Longitude - minLon) / (maxLon - minLon) * 99) - - if latIdx >= 0 && latIdx < 100 && lonIdx >= 0 && lonIdx < 100 { - grid[latIdx][lonIdx] += srcData.SignalLevel - } - } - } - - heatmapData["grid"] = grid - heatmapData["bounds"] = map[string]float64{ - "minLat": minLat, - "maxLat": maxLat, - "minLon": minLon, - "maxLon": maxLon, +func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) { + configData := map[string]interface{}{ + "origin": map[string]interface{}{ + "latitude": s.config.Origin.Latitude, + "longitude": s.config.Origin.Longitude, + "name": s.config.Origin.Name, + }, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(heatmapData) + json.NewEncoder(w).Encode(configData) +} + +func (s *Server) aircraftMapToSlice(aircraftMap map[string]parser.Aircraft) []parser.Aircraft { + aircraft := make([]parser.Aircraft, 0, len(aircraftMap)) + for _, a := range aircraftMap { + aircraft = append(aircraft, a) + } + return aircraft +} + +func (s *Server) subscribeToAircraftUpdates() { + updates := s.dump1090.Subscribe() + + for data := range updates { + message := WebSocketMessage{ + Type: "aircraft_update", + Data: map[string]interface{}{ + "now": data.Now, + "messages": data.Messages, + "aircraft": s.aircraftMapToSlice(data.Aircraft), + }, + } + + s.broadcastToWebSocketClients(message) + } } -// handleWebSocket manages WebSocket connections for real-time aircraft data streaming. -// -// This handler: -// 1. Upgrades the HTTP connection to WebSocket protocol -// 2. Registers the client for broadcast updates -// 3. Sends initial data snapshot to the client -// 4. Handles client messages (currently just ping/pong for keepalive) -// 5. Cleans up the connection when the client disconnects -// -// WebSocket clients receive periodic updates with current aircraft positions, -// source status, and system statistics. The connection is kept alive until -// the client disconnects or the server shuts down. func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { @@ -544,267 +190,79 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { } defer conn.Close() - // Register client - s.wsClientsMu.Lock() + s.wsClientsMux.Lock() s.wsClients[conn] = true - s.wsClientsMu.Unlock() + s.wsClientsMux.Unlock() - // Send initial data - s.sendInitialData(conn) + defer func() { + s.wsClientsMux.Lock() + delete(s.wsClients, conn) + s.wsClientsMux.Unlock() + }() + + data := s.dump1090.GetAircraftData() + initialMessage := WebSocketMessage{ + Type: "aircraft_update", + Data: map[string]interface{}{ + "now": data.Now, + "messages": data.Messages, + "aircraft": s.aircraftMapToSlice(data.Aircraft), + }, + } + conn.WriteJSON(initialMessage) - // Handle client messages (ping/pong) for { _, _, err := conn.ReadMessage() if err != nil { break } } - - // Unregister client - s.wsClientsMu.Lock() - delete(s.wsClients, conn) - s.wsClientsMu.Unlock() } -// sendInitialData sends a complete data snapshot to a newly connected WebSocket client. -// -// This includes: -// - All currently tracked aircraft with their state information -// - Status of all configured data sources -// - Current system statistics -// -// ICAO addresses are converted to hex strings for consistent JSON representation. -// This initial data allows the client to immediately display current aircraft -// without waiting for the next periodic update. -func (s *Server) sendInitialData(conn *websocket.Conn) { - aircraft := s.merger.GetAircraft() - sources := s.merger.GetSources() - stats := s.merger.GetStatistics() +func (s *Server) broadcastToWebSocketClients(message WebSocketMessage) { + s.wsClientsMux.RLock() + defer s.wsClientsMux.RUnlock() - // Convert ICAO keys to hex strings and filter useful aircraft - aircraftMap := make(map[string]*merger.AircraftState) - for icao, state := range aircraft { - if s.isAircraftUseful(state) { - aircraftMap[fmt.Sprintf("%06X", icao)] = state - } - } - - update := AircraftUpdate{ - Aircraft: aircraftMap, - Sources: sources, - Stats: stats, - } - - msg := WebSocketMessage{ - Type: "initial_data", - Timestamp: time.Now().Unix(), - Data: update, - } - - conn.WriteJSON(msg) -} - -// broadcastRoutine runs in a dedicated goroutine to distribute WebSocket messages. -// -// This routine: -// - Listens for broadcast messages on the broadcastChan -// - Sends messages to all connected WebSocket clients -// - 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. -func (s *Server) broadcastRoutine() { - for { - select { - case <-s.stopChan: - return - case data := <-s.broadcastChan: - s.wsClientsMu.RLock() - for conn := range s.wsClients { - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { - conn.Close() - delete(s.wsClients, conn) - } - } - s.wsClientsMu.RUnlock() + for client := range s.wsClients { + if err := client.WriteJSON(message); err != nil { + client.Close() + delete(s.wsClients, client) } } } -// periodicUpdateRoutine generates regular WebSocket updates for all connected clients. -// -// Updates are sent every second and include: -// - Current aircraft positions and state -// - Data source status updates -// - Fresh system statistics -// -// The routine uses a ticker for consistent timing and respects the shutdown -// signal. Updates are queued through broadcastUpdate() which handles the -// actual message formatting and distribution. -func (s *Server) periodicUpdateRoutine() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-s.stopChan: - return - case <-ticker.C: - s.broadcastUpdate() - } - } -} - -// broadcastUpdate creates and queues an aircraft update message for WebSocket clients. -// -// This function: -// 1. Collects current aircraft data from the merger -// 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 -// cannot keep up with updates. -func (s *Server) broadcastUpdate() { - aircraft := s.merger.GetAircraft() - sources := s.merger.GetSources() - stats := s.merger.GetStatistics() - - // Convert ICAO keys to hex strings and filter useful aircraft - aircraftMap := make(map[string]*merger.AircraftState) - for icao, state := range aircraft { - if s.isAircraftUseful(state) { - aircraftMap[fmt.Sprintf("%06X", icao)] = state - } - } - - update := AircraftUpdate{ - Aircraft: aircraftMap, - Sources: sources, - Stats: stats, - } - - msg := WebSocketMessage{ - Type: "aircraft_update", - Timestamp: time.Now().Unix(), - Data: update, - } - - if data, err := json.Marshal(msg); err == nil { - select { - case s.broadcastChan <- data: - default: - // Channel full, skip this update - } - } -} - -// handleIndex serves the main application page at the root URL. -// Returns the embedded index.html file which contains the aircraft tracking interface. -// -// Returns 404 if the index.html file is not found in the embedded assets. -func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - data, err := s.staticFiles.ReadFile("static/index.html") - if err != nil { - http.Error(w, "Page not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "text/html") - w.Write(data) -} - -// handleFavicon serves the favicon.ico file for browser tab icons. -// Returns the embedded favicon file with appropriate content-type header. -// -// Returns 404 if the favicon.ico file is not found in the embedded assets. -func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) { - data, err := s.staticFiles.ReadFile("static/favicon.ico") - if err != nil { - http.Error(w, "Favicon not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "image/x-icon") - w.Write(data) -} - -// staticFileHandler creates an HTTP handler for serving embedded static files. -// -// This handler: -// - Maps URL paths from /static/* to embedded file paths -// - Sets appropriate Content-Type headers based on file extension -// - Adds cache control headers for client-side caching (1 hour) -// - Returns 404 for missing files -// -// The handler serves files from the embedded filesystem, enabling -// single-binary deployment without external static file dependencies. func (s *Server) staticFileHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Remove /static/ prefix from URL path to get the actual file path - filePath := "static" + r.URL.Path[len("/static"):] - + filePath := "static/" + r.URL.Path + data, err := s.staticFiles.ReadFile(filePath) if err != nil { http.NotFound(w, r) return } - // Set content type ext := path.Ext(filePath) - contentType := getContentType(ext) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + switch ext { + case ".css": + contentType = "text/css" + case ".js": + contentType = "application/javascript" + case ".svg": + contentType = "image/svg+xml" + case ".html": + contentType = "text/html" + default: + contentType = "application/octet-stream" + } + } + w.Header().Set("Content-Type", contentType) - - // Cache control - w.Header().Set("Cache-Control", "public, max-age=3600") - w.Write(data) }) } -// getContentType returns the appropriate MIME type for a file extension. -// Supports common web file types used in the SkyView interface: -// - HTML, CSS, JavaScript files -// - JSON data files -// - Image formats (SVG, PNG, JPEG, ICO) -// -// Returns "application/octet-stream" for unknown extensions. -func getContentType(ext string) string { - switch ext { - case ".html": - return "text/html" - case ".css": - return "text/css" - case ".js": - return "application/javascript" - case ".json": - return "application/json" - case ".svg": - return "image/svg+xml" - case ".png": - return "image/png" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".ico": - return "image/x-icon" - default: - return "application/octet-stream" - } -} - -// enableCORS wraps an HTTP handler with Cross-Origin Resource Sharing headers. -// -// This middleware: -// - Allows requests from any origin (*) -// - Supports GET, POST, PUT, DELETE, and OPTIONS methods -// - Permits Content-Type and Authorization headers -// - Handles preflight OPTIONS requests -// -// CORS is enabled to support web applications hosted on different domains -// than the SkyView server, which is common in development and some deployment scenarios. func (s *Server) enableCORS(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") @@ -818,35 +276,4 @@ 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) -} +} \ No newline at end of file diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..61e0e64 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "embed" + "net/http" + "net/http/httptest" + "testing" + + "skyview/internal/config" +) + +//go:embed testdata/* +var testStaticFiles embed.FS + +func TestNew(t *testing.T) { + cfg := &config.Config{ + Server: config.ServerConfig{ + Address: ":8080", + Port: 8080, + }, + Dump1090: config.Dump1090Config{ + Host: "localhost", + Port: 8080, + URL: "http://localhost:8080", + }, + } + + handler := New(cfg, testStaticFiles) + if handler == nil { + t.Fatal("Expected handler to be created") + } +} + +func TestCORSHeaders(t *testing.T) { + cfg := &config.Config{ + Dump1090: config.Dump1090Config{ + URL: "http://localhost:8080", + }, + } + + handler := New(cfg, testStaticFiles) + + req := httptest.NewRequest("OPTIONS", "/api/aircraft", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Errorf("Expected CORS header, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..ad52c95 --- /dev/null +++ b/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "embed" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "skyview/internal/config" + "skyview/internal/server" +) + +//go:embed static/* +var staticFiles embed.FS + +func main() { + daemon := flag.Bool("daemon", false, "Run as daemon (background process)") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + srv := server.New(cfg, staticFiles, ctx) + + log.Printf("Starting skyview server on %s", cfg.Server.Address) + log.Printf("Connecting to dump1090 SBS-1 at %s:%d", cfg.Dump1090.Host, cfg.Dump1090.DataPort) + + httpServer := &http.Server{ + Addr: cfg.Server.Address, + Handler: srv, + } + + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } + }() + + if *daemon { + log.Printf("Running as daemon...") + select {} + } else { + log.Printf("Press Ctrl+C to stop") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Printf("Shutting down...") + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("Server shutdown error: %v", err) + } + } +} \ No newline at end of file diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh deleted file mode 100755 index 7534373..0000000 --- a/scripts/build-deb.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash -set -e - -# Build script for creating Debian package - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -BUILD_DIR="$PROJECT_DIR/build" -DEB_DIR="$PROJECT_DIR/debian" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -echo_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -echo_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Clean previous builds -echo_info "Cleaning previous builds..." -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR" - -# Change to project directory -cd "$PROJECT_DIR" - -# Build the applications -echo_info "Building SkyView applications..." -export CGO_ENABLED=0 -export GOOS=linux -export GOARCH=amd64 - -VERSION=$(git describe --tags --always --dirty) -LDFLAGS="-w -s -X main.version=$VERSION" - -# Build main skyview binary -echo_info "Building skyview..." -if ! go build -ldflags="$LDFLAGS" \ - -o "$DEB_DIR/usr/bin/skyview" \ - ./cmd/skyview; then - echo_error "Failed to build skyview" - exit 1 -fi - -# Build beast-dump utility -echo_info "Building beast-dump..." -if ! go build -ldflags="$LDFLAGS" \ - -o "$DEB_DIR/usr/bin/beast-dump" \ - ./cmd/beast-dump; then - echo_error "Failed to build beast-dump" - exit 1 -fi - -echo_info "Built binaries:" -echo_info " skyview: $(file "$DEB_DIR/usr/bin/skyview")" -echo_info " beast-dump: $(file "$DEB_DIR/usr/bin/beast-dump")" - -# Set executable permissions -chmod +x "$DEB_DIR/usr/bin/skyview" -chmod +x "$DEB_DIR/usr/bin/beast-dump" - -# Get package info -VERSION=$(grep "Version:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2) -PACKAGE=$(grep "Package:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2) -ARCH=$(grep "Architecture:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2) - -DEB_FILE="${PACKAGE}_${VERSION}_${ARCH}.deb" - -echo_info "Creating Debian package: $DEB_FILE" - -# Calculate installed size -INSTALLED_SIZE=$(du -sk "$DEB_DIR" | cut -f1) -sed -i "s/Installed-Size:.*/Installed-Size: $INSTALLED_SIZE/" "$DEB_DIR/DEBIAN/control" 2>/dev/null || \ - echo "Installed-Size: $INSTALLED_SIZE" >> "$DEB_DIR/DEBIAN/control" - -# Build the package -if dpkg-deb --root-owner-group --build "$DEB_DIR" "$BUILD_DIR/$DEB_FILE"; then - echo_info "Successfully created: $BUILD_DIR/$DEB_FILE" - - # Show package info - echo_info "Package information:" - dpkg-deb --info "$BUILD_DIR/$DEB_FILE" - - echo_info "Package contents:" - dpkg-deb --contents "$BUILD_DIR/$DEB_FILE" - - # Test the package (requires root) - if [ "$EUID" -eq 0 ]; then - echo_info "Testing package installation (as root)..." - dpkg --dry-run -i "$BUILD_DIR/$DEB_FILE" - else - echo_warn "Run as root to test package installation" - fi - - echo_info "Debian package build complete!" - echo_info "Install with: sudo dpkg -i $BUILD_DIR/$DEB_FILE" - echo_info "Or upload to repository for apt installation" -else - echo_error "Failed to create Debian package" - exit 1 -fi \ No newline at end of file diff --git a/static/aircraft-icon.svg b/static/aircraft-icon.svg new file mode 100644 index 0000000..f2489d3 --- /dev/null +++ b/static/aircraft-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/static/css/style.css b/static/css/style.css similarity index 62% rename from assets/static/css/style.css rename to static/css/style.css index 750603c..0f2e125 100644 --- a/assets/static/css/style.css +++ b/static/css/style.css @@ -193,48 +193,6 @@ body { background: #404040; } -.display-options { - position: absolute; - top: 320px; - right: 10px; - z-index: 1000; - background: rgba(45, 45, 45, 0.95); - border: 1px solid #404040; - border-radius: 8px; - padding: 1rem; - min-width: 200px; -} - -.display-options h4 { - margin-bottom: 0.5rem; - color: #ffffff; - font-size: 0.9rem; -} - -.option-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.option-group label { - display: flex; - align-items: center; - cursor: pointer; - font-size: 0.8rem; - color: #cccccc; -} - -.option-group input[type="checkbox"] { - margin-right: 0.5rem; - accent-color: #00d4ff; - transform: scale(1.1); -} - -.option-group label:hover { - color: #ffffff; -} - .legend { position: absolute; bottom: 10px; @@ -268,15 +226,11 @@ body { border: 1px solid #ffffff; } -.legend-icon.light { background: #00bfff; } /* Sky blue for light aircraft */ -.legend-icon.medium { background: #00ff88; } /* Green for medium aircraft */ -.legend-icon.large { background: #ff8c00; } /* Orange for large aircraft */ -.legend-icon.high-vortex { background: #ff4500; } /* Red-orange for high vortex large */ -.legend-icon.heavy { background: #ff0000; } /* Red for heavy aircraft */ -.legend-icon.helicopter { background: #ff00ff; } /* Magenta for helicopters */ -.legend-icon.military { background: #ff4444; } /* Red-orange for military */ -.legend-icon.ga { background: #ffff00; } /* Yellow for general aviation */ -.legend-icon.ground { background: #888888; } /* Gray for ground vehicles */ +.legend-icon.commercial { background: #00ff88; } +.legend-icon.cargo { background: #ff8c00; } +.legend-icon.military { background: #ff4444; } +.legend-icon.ga { background: #ffff00; } +.legend-icon.ground { background: #888888; } .table-controls { display: flex; @@ -408,148 +362,20 @@ body { z-index: 1000; } -/* Leaflet popup override - ensure our styles take precedence */ -.leaflet-popup-content-wrapper { - background: #2d2d2d !important; - color: #ffffff !important; - border-radius: 8px; -} - -.leaflet-popup-content { - margin: 12px !important; - color: #ffffff !important; -} - -/* Under Construction Styles */ -.under-construction { - color: #ff8c00; - font-size: 0.8em; - font-weight: normal; - margin-left: 8px; -} - -.construction-notice { - background: rgba(255, 140, 0, 0.1); - border: 1px solid #ff8c00; - border-radius: 4px; - padding: 8px; - margin: 8px 0; - font-size: 0.9em; - color: #ff8c00; - text-align: center; -} - -/* Toast Notifications */ -.toast-notification { - position: fixed; - top: 20px; - right: 20px; - background: rgba(40, 40, 40, 0.95); - border: 1px solid #555; - border-radius: 6px; - padding: 12px 20px; - color: #ffffff; - font-size: 0.9em; - max-width: 300px; - z-index: 10000; - transform: translateX(320px); - transition: transform 0.3s ease-in-out; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); -} - -.toast-notification.error { - border-color: #ff8c00; - background: rgba(255, 140, 0, 0.1); - color: #ff8c00; -} - -.toast-notification.show { - transform: translateX(0); -} - -/* Version Info */ -.version-info { - font-size: 0.6em; - color: #888; - font-weight: normal; - margin-left: 8px; -} - -/* Repository Link */ -.repo-link { - color: #888; - text-decoration: none; - font-size: 0.7em; - margin-left: 6px; - opacity: 0.6; - transition: opacity 0.2s ease, color 0.2s ease; -} - -.repo-link:hover { - color: #4a9eff; - opacity: 1; - text-decoration: none; -} - -/* Collapsible Sections */ -.collapsible-header { - cursor: pointer; - user-select: none; - display: flex; - justify-content: space-between; - align-items: center; - margin: 0 0 8px 0; - padding: 4px 0; - border-bottom: 1px solid #444; -} - -.collapsible-header:hover { - color: #4a9eff; -} - -.collapse-indicator { - font-size: 0.8em; - transition: transform 0.2s ease; - color: #888; -} - -.collapsible-header.collapsed .collapse-indicator { - transform: rotate(-90deg); -} - -.collapsible-content { - overflow: hidden; - transition: max-height 0.3s ease; - max-height: 200px; -} - -.collapsible-content.collapsed { - max-height: 0; - margin: 0; - padding: 0; -} - -.leaflet-popup-tip { - background: #2d2d2d !important; -} - .aircraft-popup { min-width: 300px; max-width: 400px; - color: #ffffff !important; } .popup-header { border-bottom: 1px solid #404040; padding-bottom: 0.5rem; margin-bottom: 0.75rem; - color: #ffffff !important; } .flight-info { font-size: 1.1rem; font-weight: bold; - color: #ffffff !important; } .icao-flag { @@ -558,27 +384,21 @@ body { } .flight-id { - color: #00a8ff !important; + color: #00a8ff; font-family: monospace; } .callsign { - color: #00ff88 !important; + color: #00ff88; } .popup-details { font-size: 0.9rem; - color: #ffffff !important; } .detail-row { margin-bottom: 0.5rem; padding: 0.25rem 0; - color: #ffffff !important; -} - -.detail-row strong { - color: #ffffff !important; } .detail-grid { @@ -595,27 +415,13 @@ body { .detail-item .label { font-size: 0.8rem; - color: #888 !important; + color: #888; margin-bottom: 0.1rem; } .detail-item .value { font-weight: bold; - color: #ffffff !important; -} - -/* Ensure all values are visible with strong contrast */ -.aircraft-popup .value, -.aircraft-popup .detail-row, -.aircraft-popup .detail-item .value { - color: #ffffff !important; - text-shadow: 1px 1px 2px rgba(0,0,0,0.5); -} - -/* Style for N/A or empty values - still visible but slightly dimmed */ -.detail-item .value.no-data { - color: #aaaaaa !important; - font-style: italic; + color: #ffffff; } @media (max-width: 768px) { diff --git a/assets/static/favicon.ico b/static/favicon.ico similarity index 100% rename from assets/static/favicon.ico rename to static/favicon.ico diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..d8b3b0f --- /dev/null +++ b/static/index.html @@ -0,0 +1,152 @@ + + + + + + SkyView - ADS-B Aircraft Tracker + + + + + +
+
+

SkyView

+
+
+
+
+
+
+
+
UTC
+
+
+
+
+
+
+
Last Update
+
+
+
+
+ 0 aircraft + Connected +
+
+ +
+
+ + + +
+ +
+
+
+ + + +
+ +
+

Aircraft Types

+
+ + Commercial +
+
+ + Cargo +
+
+ + Military +
+
+ + General Aviation +
+
+ + Ground +
+
+
+ +
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
ICAOFlightSquawkAltitudeSpeedDistanceTrackMsgsAgeRSSI
+
+
+ +
+
+
+

Total Aircraft

+
0
+
+
+

Messages/sec

+
0
+
+
+

Avg RSSI

+
0 dBFS
+
+
+

Max Range

+
0 nm
+
+
+
+
+

Aircraft Count (24h)

+ +
+
+

Message Rate

+ +
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..c69a031 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,832 @@ +class SkyView { + constructor() { + this.map = null; + this.aircraftMarkers = new Map(); + this.aircraftTrails = new Map(); + this.historicalTracks = new Map(); + this.websocket = null; + this.aircraftData = []; + this.showTrails = false; + this.showHistoricalTracks = false; + this.currentView = 'map'; + this.charts = {}; + this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' }; + this.lastUpdateTime = new Date(); + + this.init(); + } + + init() { + this.loadConfig().then(() => { + this.initializeViews(); + this.initializeMap(); + this.initializeWebSocket(); + this.initializeEventListeners(); + this.initializeCharts(); + this.initializeClocks(); + + this.startPeriodicUpdates(); + }); + } + + async loadConfig() { + try { + const response = await fetch('/api/config'); + const config = await response.json(); + if (config.origin) { + this.origin = config.origin; + } + } catch (error) { + console.warn('Failed to load config, using defaults:', error); + } + } + + 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); + + viewButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + views.forEach(v => v.classList.remove('active')); + document.getElementById(viewId).classList.add('active'); + }); + }); + } + + switchView(view) { + this.currentView = view; + if (view === 'map' && this.map) { + setTimeout(() => this.map.invalidateSize(), 100); + } + } + + initializeMap() { + this.map = L.map('map', { + center: [this.origin.latitude, this.origin.longitude], + zoom: 8, + zoomControl: true + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + + L.marker([this.origin.latitude, this.origin.longitude], { + icon: L.divIcon({ + html: '
', + className: 'origin-marker', + iconSize: [16, 16], + iconAnchor: [8, 8] + }) + }).addTo(this.map).bindPopup(`Origin
${this.origin.name}`); + + L.circle([this.origin.latitude, this.origin.longitude], { + radius: 185200, + fillColor: 'transparent', + color: '#404040', + weight: 1, + opacity: 0.5 + }).addTo(this.map); + + const centerBtn = document.getElementById('center-map'); + centerBtn.addEventListener('click', () => this.centerMapOnAircraft()); + + const trailsBtn = document.getElementById('toggle-trails'); + trailsBtn.addEventListener('click', () => this.toggleTrails()); + + const historyBtn = document.getElementById('toggle-history'); + historyBtn.addEventListener('click', () => this.toggleHistoricalTracks()); + } + + initializeWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + this.websocket = new WebSocket(wsUrl); + + this.websocket.onopen = () => { + document.getElementById('connection-status').textContent = 'Connected'; + document.getElementById('connection-status').className = 'connection-status connected'; + }; + + this.websocket.onclose = () => { + document.getElementById('connection-status').textContent = 'Disconnected'; + document.getElementById('connection-status').className = 'connection-status disconnected'; + setTimeout(() => this.initializeWebSocket(), 5000); + }; + + this.websocket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'aircraft_update') { + this.updateAircraftData(message.data); + } + }; + } + + initializeEventListeners() { + const searchInput = document.getElementById('search-input'); + const sortSelect = document.getElementById('sort-select'); + + searchInput.addEventListener('input', () => this.filterAircraftTable()); + sortSelect.addEventListener('change', () => this.sortAircraftTable()); + } + + initializeCharts() { + const aircraftCtx = document.getElementById('aircraft-chart').getContext('2d'); + const messageCtx = document.getElementById('message-chart').getContext('2d'); + + this.charts.aircraft = new Chart(aircraftCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Aircraft Count', + data: [], + borderColor: '#00a8ff', + backgroundColor: 'rgba(0, 168, 255, 0.1)', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + y: { beginAtZero: true } + } + } + }); + + this.charts.messages = new Chart(messageCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Messages/sec', + data: [], + borderColor: '#2ecc71', + backgroundColor: 'rgba(46, 204, 113, 0.1)', + tension: 0.4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false } + }, + scales: { + y: { beginAtZero: true } + } + } + }); + } + + initializeClocks() { + this.updateClocks(); + setInterval(() => this.updateClocks(), 1000); + } + + 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)`; + } + + updateAircraftData(data) { + this.aircraftData = data.aircraft || []; + this.lastUpdateTime = new Date(); + this.updateMapMarkers(); + this.updateAircraftTable(); + this.updateStats(); + + document.getElementById('aircraft-count').textContent = `${this.aircraftData.length} aircraft`; + } + + updateMapMarkers() { + const currentHexCodes = new Set(this.aircraftData.map(a => a.hex)); + + this.aircraftMarkers.forEach((marker, hex) => { + if (!currentHexCodes.has(hex)) { + this.map.removeLayer(marker); + this.aircraftMarkers.delete(hex); + } + }); + + this.aircraftData.forEach(aircraft => { + if (!aircraft.lat || !aircraft.lon) return; + + const pos = [aircraft.lat, aircraft.lon]; + + if (this.aircraftMarkers.has(aircraft.hex)) { + const marker = this.aircraftMarkers.get(aircraft.hex); + marker.setLatLng(pos); + this.updateMarkerRotation(marker, aircraft.track, aircraft); + this.updatePopupContent(marker, aircraft); + } else { + const marker = this.createAircraftMarker(aircraft, pos); + this.aircraftMarkers.set(aircraft.hex, marker); + } + + if (this.showTrails) { + this.updateTrail(aircraft.hex, pos); + } + + if (this.showHistoricalTracks && aircraft.track_history && aircraft.track_history.length > 1) { + this.displayHistoricalTrack(aircraft.hex, aircraft.track_history, aircraft.flight); + } + }); + } + + createAircraftMarker(aircraft, pos) { + const hasPosition = aircraft.lat && aircraft.lon; + const size = hasPosition ? 24 : 18; + + const icon = L.divIcon({ + html: this.getAircraftIcon(aircraft), + className: 'aircraft-marker', + iconSize: [size, size], + iconAnchor: [size/2, size/2] + }); + + const marker = L.marker(pos, { icon }).addTo(this.map); + + marker.bindPopup(this.createPopupContent(aircraft), { + className: 'aircraft-popup' + }); + + marker.on('click', () => { + if (this.showHistoricalTracks) { + this.loadAircraftHistory(aircraft.hex); + } + }); + + return marker; + } + + getAircraftType(aircraft) { + if (aircraft.on_ground) return 'ground'; + + // Determine type based on flight number patterns and characteristics + const flight = aircraft.flight || ''; + + // Cargo airlines (simplified patterns) + if (/^(UPS|FDX|FX|ABX|ATN|5Y|QY)/i.test(flight)) return 'cargo'; + + // Military patterns (basic) + if (/^(RCH|CNV|MAC|EVAC|ARMY|NAVY|AF|USAF)/i.test(flight)) return 'military'; + + // General aviation (no airline code pattern) + if (flight.length > 0 && !/^[A-Z]{2,3}[0-9]/.test(flight)) return 'ga'; + + // Default commercial + return 'commercial'; + } + + getAircraftIcon(aircraft) { + const rotation = aircraft.track || 0; + const hasPosition = aircraft.lat && aircraft.lon; + const type = this.getAircraftType(aircraft); + const size = hasPosition ? 24 : 18; + + let color, icon; + + switch (type) { + case 'cargo': + color = hasPosition ? "#ff8c00" : "#666666"; + icon = ` + + + `; + break; + case 'military': + color = hasPosition ? "#ff4444" : "#666666"; + icon = ` + + + + `; + break; + case 'ga': + color = hasPosition ? "#ffff00" : "#666666"; + icon = ` + + `; + break; + case 'ground': + color = "#888888"; + icon = ` + G`; + break; + default: // commercial + color = hasPosition ? "#00ff88" : "#666666"; + icon = ` + + `; + break; + } + + return ` + ${icon} + `; + } + + updateMarkerRotation(marker, track, aircraft) { + if (track !== undefined) { + const hasPosition = aircraft.lat && aircraft.lon; + const size = hasPosition ? 24 : 18; + + const icon = L.divIcon({ + html: this.getAircraftIcon(aircraft), + className: 'aircraft-marker', + iconSize: [size, size], + iconAnchor: [size/2, size/2] + }); + marker.setIcon(icon); + } + } + + createPopupContent(aircraft) { + const type = this.getAircraftType(aircraft); + const distance = this.calculateDistance(aircraft); + const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A'; + const altitudeM = aircraft.alt_baro ? Math.round(aircraft.alt_baro * 0.3048) : 'N/A'; + const speedKmh = aircraft.gs ? Math.round(aircraft.gs * 1.852) : 'N/A'; + const trackText = aircraft.track ? `${aircraft.track}° (${this.getTrackDirection(aircraft.track)})` : 'N/A'; + + return ` +
+ + + +
+ `; + } + + getCountryFlag(country) { + const flags = { + 'United States': '🇺🇸', + 'United Kingdom': '🇬🇧', + 'Germany': '🇩🇪', + 'France': '🇫🇷', + 'Netherlands': '🇳🇱', + 'Sweden': '🇸🇪', + 'Spain': '🇪🇸', + 'Italy': '🇮🇹', + 'Canada': '🇨🇦', + 'Japan': '🇯🇵', + 'Denmark': '🇩🇰', + 'Austria': '🇦🇹', + 'Belgium': '🇧🇪', + 'Finland': '🇫🇮', + 'Greece': '🇬🇷' + }; + return flags[country] || '🏳️'; + } + + getTrackDirection(track) { + const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; + const index = Math.round(track / 22.5) % 16; + return directions[index]; + } + + updatePopupContent(marker, aircraft) { + marker.setPopupContent(this.createPopupContent(aircraft)); + } + + updateTrail(hex, pos) { + if (!this.aircraftTrails.has(hex)) { + this.aircraftTrails.set(hex, []); + } + + const trail = this.aircraftTrails.get(hex); + trail.push(pos); + + if (trail.length > 50) { + trail.shift(); + } + + const polyline = L.polyline(trail, { + color: '#00a8ff', + weight: 2, + opacity: 0.6 + }).addTo(this.map); + } + + toggleTrails() { + this.showTrails = !this.showTrails; + + if (!this.showTrails) { + this.aircraftTrails.clear(); + this.map.eachLayer(layer => { + if (layer instanceof L.Polyline) { + this.map.removeLayer(layer); + } + }); + } + + document.getElementById('toggle-trails').textContent = + this.showTrails ? 'Hide Trails' : 'Show Trails'; + } + + centerMapOnAircraft() { + if (this.aircraftData.length === 0) return; + + const validAircraft = this.aircraftData.filter(a => a.lat && a.lon); + if (validAircraft.length === 0) return; + + const group = new L.featureGroup( + validAircraft.map(a => L.marker([a.lat, a.lon])) + ); + + this.map.fitBounds(group.getBounds().pad(0.1)); + } + + updateAircraftTable() { + const tbody = document.getElementById('aircraft-tbody'); + tbody.innerHTML = ''; + + let filteredData = [...this.aircraftData]; + + const searchTerm = document.getElementById('search-input').value.toLowerCase(); + if (searchTerm) { + filteredData = filteredData.filter(aircraft => + (aircraft.flight && aircraft.flight.toLowerCase().includes(searchTerm)) || + aircraft.hex.toLowerCase().includes(searchTerm) || + (aircraft.squawk && aircraft.squawk.includes(searchTerm)) + ); + } + + const sortBy = document.getElementById('sort-select').value; + this.sortAircraft(filteredData, sortBy); + + filteredData.forEach(aircraft => { + const type = this.getAircraftType(aircraft); + const country = aircraft.country || 'Unknown'; + const countryFlag = this.getCountryFlag(country); + const age = aircraft.seen ? aircraft.seen.toFixed(0) : '0'; + const distance = this.calculateDistance(aircraft); + const distanceStr = distance ? distance.toFixed(1) : '-'; + const altitudeStr = aircraft.alt_baro ? + (aircraft.alt_baro >= 0 ? `▲ ${aircraft.alt_baro}` : `▼ ${Math.abs(aircraft.alt_baro)}`) : + '-'; + + const row = document.createElement('tr'); + // Color code RSSI values + let rssiStr = '-'; + let rssiClass = ''; + if (aircraft.rssi) { + const rssi = aircraft.rssi; + rssiStr = rssi.toFixed(1); + if (rssi > -10) rssiClass = 'rssi-strong'; + else if (rssi > -20) rssiClass = 'rssi-good'; + else if (rssi > -30) rssiClass = 'rssi-weak'; + else rssiClass = 'rssi-poor'; + } + + row.innerHTML = ` + ${aircraft.hex} + ${aircraft.flight || '-'} + ${aircraft.squawk || '-'} + ${altitudeStr} + ${aircraft.gs || '-'} + ${distanceStr} + ${aircraft.track || '-'}° + ${aircraft.messages || '-'} + ${age} + ${rssiStr} + `; + + row.addEventListener('click', () => { + if (aircraft.lat && aircraft.lon) { + this.switchView('map'); + document.getElementById('map-view-btn').classList.add('active'); + document.querySelectorAll('.view-btn:not(#map-view-btn)').forEach(btn => + btn.classList.remove('active')); + document.querySelectorAll('.view:not(#map-view)').forEach(view => + view.classList.remove('active')); + document.getElementById('map-view').classList.add('active'); + + this.map.setView([aircraft.lat, aircraft.lon], 12); + + const marker = this.aircraftMarkers.get(aircraft.hex); + if (marker) { + marker.openPopup(); + } + } + }); + + tbody.appendChild(row); + }); + } + + filterAircraftTable() { + this.updateAircraftTable(); + } + + sortAircraftTable() { + this.updateAircraftTable(); + } + + sortAircraft(aircraft, sortBy) { + aircraft.sort((a, b) => { + switch (sortBy) { + case 'distance': + return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity); + case 'altitude': + return (b.alt_baro || 0) - (a.alt_baro || 0); + case 'speed': + return (b.gs || 0) - (a.gs || 0); + case 'flight': + return (a.flight || a.hex).localeCompare(b.flight || b.hex); + case 'icao': + return a.hex.localeCompare(b.hex); + case 'squawk': + return (a.squawk || '').localeCompare(b.squawk || ''); + case 'age': + return (a.seen || 0) - (b.seen || 0); + case 'rssi': + return (b.rssi || -999) - (a.rssi || -999); + default: + return 0; + } + }); + } + + calculateDistance(aircraft) { + if (!aircraft.lat || !aircraft.lon) return null; + + const centerLat = this.origin.latitude; + const centerLng = this.origin.longitude; + + const R = 3440.065; + const dLat = (aircraft.lat - centerLat) * Math.PI / 180; + const dLon = (aircraft.lon - centerLng) * Math.PI / 180; + const a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(centerLat * Math.PI / 180) * Math.cos(aircraft.lat * Math.PI / 180) * + Math.sin(dLon/2) * Math.sin(dLon/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; // Return a number, not a string + } + + updateStats() { + document.getElementById('total-aircraft').textContent = this.aircraftData.length; + + const withPosition = this.aircraftData.filter(a => a.lat && a.lon).length; + const avgAltitude = this.aircraftData + .filter(a => a.alt_baro) + .reduce((sum, a) => sum + a.alt_baro, 0) / this.aircraftData.length || 0; + + const distances = this.aircraftData + .map(a => this.calculateDistance(a)) + .filter(d => d !== null); + const maxDistance = distances.length > 0 ? Math.max(...distances) : 0; + + document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} nm`; + + this.updateChartData(); + } + + updateChartData() { + const now = new Date(); + const timeLabel = now.toLocaleTimeString(); + + if (this.charts.aircraft) { + const chart = this.charts.aircraft; + chart.data.labels.push(timeLabel); + chart.data.datasets[0].data.push(this.aircraftData.length); + + if (chart.data.labels.length > 20) { + chart.data.labels.shift(); + chart.data.datasets[0].data.shift(); + } + + chart.update('none'); + } + + if (this.charts.messages) { + const chart = this.charts.messages; + const totalMessages = this.aircraftData.reduce((sum, a) => sum + (a.messages || 0), 0); + const messagesPerSec = totalMessages / 60; + + chart.data.labels.push(timeLabel); + chart.data.datasets[0].data.push(messagesPerSec); + + if (chart.data.labels.length > 20) { + chart.data.labels.shift(); + chart.data.datasets[0].data.shift(); + } + + chart.update('none'); + + document.getElementById('messages-sec').textContent = Math.round(messagesPerSec); + } + } + + startPeriodicUpdates() { + setInterval(() => { + if (this.websocket.readyState !== WebSocket.OPEN) { + this.fetchAircraftData(); + } + }, 5000); + + setInterval(() => { + this.fetchStatsData(); + }, 10000); + } + + async fetchAircraftData() { + try { + const response = await fetch('/api/aircraft'); + const data = await response.json(); + this.updateAircraftData(data); + } catch (error) { + console.error('Failed to fetch aircraft data:', error); + } + } + + async fetchStatsData() { + try { + const response = await fetch('/api/stats'); + const stats = await response.json(); + + if (stats.total && stats.total.messages) { + const messagesPerSec = stats.total.messages.last1min / 60; + document.getElementById('messages-sec').textContent = Math.round(messagesPerSec); + } + + // Calculate average RSSI from aircraft data + const aircraftWithRSSI = this.aircraftData.filter(a => a.rssi); + if (aircraftWithRSSI.length > 0) { + const avgRSSI = aircraftWithRSSI.reduce((sum, a) => sum + a.rssi, 0) / aircraftWithRSSI.length; + document.getElementById('signal-strength').textContent = `${avgRSSI.toFixed(1)} dBFS`; + } else { + document.getElementById('signal-strength').textContent = '0 dBFS'; + } + } catch (error) { + console.error('Failed to fetch stats:', error); + } + } + + toggleHistoricalTracks() { + this.showHistoricalTracks = !this.showHistoricalTracks; + + const btn = document.getElementById('toggle-history'); + btn.textContent = this.showHistoricalTracks ? 'Hide History' : 'Show History'; + + if (!this.showHistoricalTracks) { + this.clearAllHistoricalTracks(); + } + } + + async loadAircraftHistory(hex) { + try { + const response = await fetch(`/api/aircraft/${hex}/history`); + const data = await response.json(); + + if (data.track_history && data.track_history.length > 1) { + this.displayHistoricalTrack(hex, data.track_history, data.flight); + } + } catch (error) { + console.error('Failed to load aircraft history:', error); + } + } + + displayHistoricalTrack(hex, trackHistory, flight) { + this.clearHistoricalTrack(hex); + + const points = trackHistory.map(point => [point.lat, point.lon]); + + const polyline = L.polyline(points, { + color: '#ff6b6b', + weight: 3, + opacity: 0.8, + dashArray: '5, 5' + }).addTo(this.map); + + polyline.bindPopup(`Historical Track
${flight || hex}
${trackHistory.length} points`); + + this.historicalTracks.set(hex, polyline); + + // Add start/end markers + if (trackHistory.length > 0) { + const start = trackHistory[0]; + const end = trackHistory[trackHistory.length - 1]; + + L.circleMarker([start.lat, start.lon], { + color: '#ffffff', + fillColor: '#00ff00', + fillOpacity: 0.8, + radius: 4 + }).addTo(this.map).bindPopup(`Start
${new Date(start.timestamp).toLocaleString()}`); + + L.circleMarker([end.lat, end.lon], { + color: '#ffffff', + fillColor: '#ff0000', + fillOpacity: 0.8, + radius: 4 + }).addTo(this.map).bindPopup(`End
${new Date(end.timestamp).toLocaleString()}`); + } + } + + clearHistoricalTrack(hex) { + if (this.historicalTracks.has(hex)) { + this.map.removeLayer(this.historicalTracks.get(hex)); + this.historicalTracks.delete(hex); + } + } + + clearAllHistoricalTracks() { + this.historicalTracks.forEach(track => { + this.map.removeLayer(track); + }); + this.historicalTracks.clear(); + + // Also remove start/end markers + this.map.eachLayer(layer => { + if (layer instanceof L.CircleMarker) { + this.map.removeLayer(layer); + } + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + new SkyView(); +}); \ No newline at end of file