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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Map
- Table
- Statistics
- Coverage
- 3D Radar
-
-
-
-
-
-
-
-
- Center Map
- Reset Map
- Show Trails
- Show Sources
- 🌙 Night Mode
-
-
-
-
-
-
-
-
ADS-B Categories
-
-
- Light < 7000kg
-
-
-
- Medium 7000-34000kg
-
-
-
- Large 34000-136000kg
-
-
-
- High Vortex Large
-
-
-
- Heavy > 136000kg
-
-
-
- Rotorcraft
-
-
-
- Glider/Ultralight
-
-
-
- Surface Vehicle
-
-
-
Sources
-
-
-
-
-
-
-
-
-
- Distance
- Altitude
- Speed
- Flight
- ICAO
- Squawk
- Signal
- Age
-
-
- All Sources
-
-
-
-
-
-
- ICAO
- Flight
- Squawk
- Altitude
- Speed
- Distance
- Track
- Sources
- Signal
- Age
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
- Select Source
-
- Toggle Heatmap
-
-
-
-
-
-
-
-
🚧 3D Controls Under Construction
-
Reset View
-
Auto Rotate
-
-
- Range: 100 km
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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 `
-
- `;
- }
-
- 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 = 'Select Source ';
-
- 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 = 'All Sources ';
-
- // 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
+
+
+
+
+
+
+
+
+
+
+ Map
+ Table
+ Stats
+
+
+
+
+
+ Center Map
+ Toggle Trails
+ Show History
+
+
+
+
Aircraft Types
+
+
+ Commercial
+
+
+
+ Cargo
+
+
+
+ Military
+
+
+
+ General Aviation
+
+
+
+ Ground
+
+
+
+
+
+
+
+
+ Distance
+ Altitude
+ Speed
+ Flight
+ ICAO
+ Squawk
+ Age
+ RSSI
+
+
+
+
+
+
+ ICAO
+ Flight
+ Squawk
+ Altitude
+ Speed
+ Distance
+ Track
+ Msgs
+ Age
+ RSSI
+
+
+
+
+
+
+
+
+
+
+
+
+
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}
+
+ `;
+
+ 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