Add ADS-B emitter category support via dump1090 JSON API

Poll dump1090's aircraft.json endpoint to get real transponder-reported
aircraft categories (A1=Light, A3=Large, A5=Heavy, A7=Rotorcraft, etc.)
instead of relying solely on heuristics. The SBS protocol on port 30003
doesn't include this field, but the JSON API on port 80 does. Categories
are polled every 5 seconds and merged into flight data. Client uses
emitter category when available, falling back to heuristic classification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 09:12:08 -07:00
parent ed4397b21b
commit 883ef20ced
3 changed files with 150 additions and 44 deletions
+41 -10
View File
@@ -25,8 +25,9 @@ Access at http://localhost:{web_port} (configured in config.json)
- **server.py** - Python WebSocket server using aiohttp - **server.py** - Python WebSocket server using aiohttp
- Reads configuration from config.json - Reads configuration from config.json
- Auto-scans for ADS-B receivers or connects to configured IPs - Auto-scans for ADS-B receivers or connects to configured IPs
- Parses SBS/BaseStation format messages from receivers - Parses SBS/BaseStation format messages from receivers (port 30003)
- Broadcasts flight data to connected WebSocket clients every 1 second - Polls dump1090 JSON API (`/dump1090/data/aircraft.json`) every 5s for ADS-B emitter categories
- Broadcasts flight data (including emitter category) to WebSocket clients every 1 second
- Cleans up flights not seen in 60 seconds - Cleans up flights not seen in 60 seconds
- Serves static files, admin panel, setup wizard, and APIs - Serves static files, admin panel, setup wizard, and APIs
- Authentication via bcrypt password hashing and session cookies - Authentication via bcrypt password hashing and session cookies
@@ -58,13 +59,42 @@ All PNG sprites face right (eastward) and are flipped in-canvas for westbound ai
### Aircraft Type Detection Logic ### Aircraft Type Detection Logic
Priority order in categorization: Two-tier system: real ADS-B emitter categories when available, heuristic fallback otherwise.
1. **Helicopter**: altitude < 5000 ft AND speed < 150 knots
2. **Heavy (747/A380)**: specific callsigns OR altitude > 42000 ft OR speed > 550 knots #### Tier 1: ADS-B Emitter Category (from dump1090 JSON API)
3. **Wide Body**: altitude > 40000 ft OR speed > 500 knots
4. **Regional Jet**: specific callsigns OR (altitude < 25000 ft AND speed < 350 knots) The server polls `http://{receiver_ip}/dump1090/data/aircraft.json` every 5 seconds. The SBS/BaseStation protocol (port 30003) does **not** include emitter category, but the dump1090/readsb JSON API exposes the raw ADS-B transponder category field. When available, this is authoritative and always preferred over heuristics.
5. **Small Prop**: N-prefix callsigns OR (altitude < 10000 ft AND speed < 200 knots)
6. **Narrow Body**: default for remaining aircraft Mapping from DO-260B emitter categories to sprite types:
| ADS-B Category | Description | Sprite |
|---|---|---|
| A1 | Light (< 15,500 lbs) | smallProp |
| A2 | Small (15,500 - 75,000 lbs) | regionalJet |
| A3 | Large (75,000 - 300,000 lbs) | narrowBody |
| A4 | High vortex large (B757) | narrowBody |
| A5 | Heavy (> 300,000 lbs) | wideBody or heavy (by altitude/speed) |
| A6 | High performance (> 5g, > 400 kts) | narrowBody |
| A7 | Rotorcraft | helicopter |
| B1 | Glider / sailplane | smallProp |
| B2 | Lighter-than-air | smallProp |
| B4 | Skydiver drop plane | smallProp |
| B6 | UAV | smallProp |
Once an emitter category is received for an aircraft, it is locked in and heuristic re-evaluation is skipped.
#### Tier 2: Heuristic Fallback (when no emitter category available)
Priority order when ADS-B category is not available (some transponders don't broadcast it):
1. **Helicopter callsigns**: known operator prefixes (LFE, MED, CHP, PHI, ERA, BHS) or keywords (LIFE, MERCY, COPTER, etc.)
2. **Heavy/Wide body callsigns**: major airline ICAO prefixes (CPA, UAE, ETH, QTR, SIA, AAL, DAL, UAL, FDX, UPS, etc.) + altitude/speed thresholds
3. **Altitude/speed extremes**: altitude > 42000 ft or speed > 550 kts = heavy; > 40000 ft or > 500 kts = wideBody
4. **N-number (US GA registration)**: pattern `N` + digit + 2-4 alphanumerics = smallProp (or narrowBody if above 25000 ft)
5. **Regional airline callsigns**: SKW, RPA, ASH, PDT, CHQ, ENY, JIA, CPZ
6. **Speed/altitude heuristics**: speed < 60 + alt < 5000 = helicopter; alt < 18000 + speed < 300 = smallProp or regionalJet
7. **Altitude-only**: < 10000 ft = smallProp, < 25000 ft = regionalJet, higher = narrowBody
8. **Default**: narrowBody (re-evaluated when better data arrives)
Important: `speed=0` or `altitude=0` means data not yet received, not actual zero. The system re-categorizes when better data arrives (speed > 0 and callsign present).
### View Direction Controls ### View Direction Controls
@@ -77,7 +107,8 @@ The viewer can rotate between cardinal directions (N/E/S/W), showing aircraft in
### External APIs ### External APIs
- **Open-Meteo**: Weather and sunrise/sunset data (updates every 10 minutes) - **Open-Meteo**: Weather and sunrise/sunset data (updates every 10 minutes)
- **ADS-B Receivers**: SBS/BaseStation protocol on port 30003 - **ADS-B Receivers**: SBS/BaseStation protocol on port 30003 (flight data)
- **dump1090 JSON API**: `http://{receiver}/dump1090/data/aircraft.json` on port 80 (emitter categories, polled every 5s)
## Dependencies ## Dependencies
+59 -33
View File
@@ -749,9 +749,18 @@ class ADSBit {
flights.forEach(flight => { flights.forEach(flight => {
if (flight.lat && flight.lon && flight.altitude) { if (flight.lat && flight.lon && flight.altitude) {
this.flights.set(flight.icao, flight); this.flights.set(flight.icao, flight);
// Categorize aircraft type - re-evaluate when speed data arrives // Use ADS-B emitter category if available (from dump1090 JSON API),
// since early messages often lack speed, leading to wrong guesses // otherwise fall back to heuristics. Re-evaluate when new data arrives.
if (!this.aircraftTypes.has(flight.icao) || (flight.speed > 0 && flight.callsign)) { const hasEmitterCat = flight.category && flight.category.length >= 2;
const cached = this.aircraftTypes.get(flight.icao);
if (hasEmitterCat && (!cached || !cached.fromEmitter)) {
// Real ADS-B emitter category arrived - always prefer it
this.categorizeFromEmitter(flight);
} else if (!cached) {
// No data yet - use heuristics
this.categorizeAircraft(flight);
} else if (!cached.fromEmitter && (flight.speed > 0 && flight.callsign)) {
// Better heuristic data arrived, re-evaluate
this.categorizeAircraft(flight); this.categorizeAircraft(flight);
} }
} }
@@ -759,15 +768,42 @@ class ADSBit {
// Aircraft count is now updated in drawAircraft() with visible/total format // Aircraft count is now updated in drawAircraft() with visible/total format
} }
categorizeFromEmitter(flight) {
// Map ADS-B emitter category to sprite type
// See DO-260B Table 2-75 for full list
const cat = flight.category;
let type = 'narrowBody';
switch (cat) {
case 'A1': type = 'smallProp'; break; // Light (< 15,500 lbs)
case 'A2': type = 'regionalJet'; break; // Small (15,500 - 75,000 lbs)
case 'A3': type = 'narrowBody'; break; // Large (75,000 - 300,000 lbs)
case 'A4': type = 'narrowBody'; break; // High vortex large (B757)
case 'A5': // Heavy (> 300,000 lbs)
// Distinguish wideBody vs heavy sprite by altitude/speed
type = ((flight.altitude > 42000) || (flight.speed > 550)) ? 'heavy' : 'wideBody';
break;
case 'A6': type = 'narrowBody'; break; // High performance (> 5g, > 400 kts)
case 'A7': type = 'helicopter'; break; // Rotorcraft
case 'B1': type = 'smallProp'; break; // Glider / sailplane
case 'B2': type = 'smallProp'; break; // Lighter-than-air
case 'B4': type = 'smallProp'; break; // Skydiver drop plane
case 'B6': type = 'smallProp'; break; // UAV
default: type = 'narrowBody'; break; // Unknown or surface vehicles
}
this.aircraftTypes.set(flight.icao, { type, fromEmitter: true });
}
categorizeAircraft(flight) { categorizeAircraft(flight) {
// Categorize aircraft based on available data // Heuristic fallback when ADS-B emitter category is not available.
// Priority: 1. Callsign-based, 2. Altitude/Speed heuristics, 3. Default // Priority: 1. Callsign-based, 2. Altitude/Speed heuristics, 3. Default
// //
// NOTE: speed=0 or altitude=0 means data not yet received, so we // NOTE: speed=0 or altitude=0 means data not yet received, so we
// avoid making assumptions from missing data. The caller should // avoid making assumptions from missing data. The caller should
// re-categorize once better data arrives. // re-categorize once better data arrives.
let category = 'narrowBody'; // Default let type = 'narrowBody'; // Default
const callsign = (flight.callsign || '').trim(); const callsign = (flight.callsign || '').trim();
const altitude = flight.altitude || 0; const altitude = flight.altitude || 0;
@@ -793,67 +829,56 @@ class ADSBit {
// --- Apply rules --- // --- Apply rules ---
// 1. Helicopter: callsign match + reasonable flight envelope // 1. Helicopter: callsign match
if (isHeliCallsign) { if (isHeliCallsign) {
category = 'helicopter'; type = 'helicopter';
} }
// 2. Heavy / Wide body // 2. Heavy / Wide body
else if (heavyCallsigns.includes(airline)) { else if (heavyCallsigns.includes(airline)) {
category = (hasAlt && altitude > 42000) || (hasSpeed && speed > 550) ? 'heavy' : 'wideBody'; type = (hasAlt && altitude > 42000) || (hasSpeed && speed > 550) ? 'heavy' : 'wideBody';
} }
else if ((hasAlt && altitude > 42000) || (hasSpeed && speed > 550)) { else if ((hasAlt && altitude > 42000) || (hasSpeed && speed > 550)) {
category = 'heavy'; type = 'heavy';
} }
else if ((hasAlt && altitude > 40000) || (hasSpeed && speed > 500)) { else if ((hasAlt && altitude > 40000) || (hasSpeed && speed > 500)) {
category = 'wideBody'; type = 'wideBody';
} }
// 3. N-number = general aviation (small prop unless data says otherwise) // 3. N-number = general aviation (small prop unless data says otherwise)
else if (isNNumber) { else if (isNNumber) {
// N-numbers at high altitude/speed are likely turboprops or light jets type = (hasAlt && altitude > 25000) ? 'narrowBody' : 'smallProp';
if (hasAlt && altitude > 25000) {
category = 'narrowBody';
} else {
category = 'smallProp';
}
} }
// 4. Regional jets // 4. Regional jets
else if (regionalCallsigns.includes(airline)) { else if (regionalCallsigns.includes(airline)) {
category = 'regionalJet'; type = 'regionalJet';
} }
// 5. Speed/altitude heuristics (only when we have real data) // 5. Speed/altitude heuristics (only when we have real data)
else if (hasSpeed && hasAlt) { else if (hasSpeed && hasAlt) {
if (speed < 60 && altitude < 5000) { if (speed < 60 && altitude < 5000) {
// Very slow + very low = likely helicopter type = 'helicopter';
category = 'helicopter';
} else if (altitude < 18000 && speed < 300) { } else if (altitude < 18000 && speed < 300) {
// Below FL180 at moderate speed type = (speed < 170 && altitude < 12000) ? 'smallProp' : 'regionalJet';
if (speed < 170 && altitude < 12000) {
category = 'smallProp';
} else { } else {
category = 'regionalJet'; type = 'narrowBody';
}
} else {
category = 'narrowBody';
} }
} }
// 6. Altitude-only heuristic // 6. Altitude-only heuristic
else if (hasAlt && !hasSpeed) { else if (hasAlt && !hasSpeed) {
if (altitude < 10000) { if (altitude < 10000) {
category = 'smallProp'; // Low altitude, unknown speed - guess GA type = 'smallProp';
} else if (altitude < 25000) { } else if (altitude < 25000) {
category = 'regionalJet'; type = 'regionalJet';
} else { } else {
category = 'narrowBody'; type = 'narrowBody';
} }
} }
// 7. No useful data yet - stay with default // 7. No useful data yet - stay with default
// (will be re-categorized on next update)
this.aircraftTypes.set(flight.icao, category); this.aircraftTypes.set(flight.icao, { type, fromEmitter: false });
} }
getAircraftSprite(icao) { getAircraftSprite(icao) {
const category = this.aircraftTypes.get(icao) || 'narrowBody'; const cached = this.aircraftTypes.get(icao);
const category = cached ? cached.type : 'narrowBody';
return this.sprites[category] || this.sprites.narrowBody; return this.sprites[category] || this.sprites.narrowBody;
} }
@@ -1707,7 +1732,8 @@ class ADSBit {
} }
// Get aircraft category and image // Get aircraft category and image
const category = this.aircraftTypes.get(flight.icao) || 'narrowBody'; const cachedType = this.aircraftTypes.get(flight.icao);
const category = cachedType ? cachedType.type : 'narrowBody';
const aircraftImage = this.aircraftImages[category]; const aircraftImage = this.aircraftImages[category];
const imageLoaded = this.aircraftImagesLoaded[category]; const imageLoaded = this.aircraftImagesLoaded[category];
+50 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime from datetime import datetime
from typing import Dict, Set, List from typing import Dict, Set, List
from aiohttp import web from aiohttp import web, ClientSession, ClientTimeout
import netifaces import netifaces
import bcrypt import bcrypt
@@ -166,6 +166,7 @@ flights: Dict[str, dict] = {}
connected_clients: Set = set() connected_clients: Set = set()
receivers: List[str] = [] receivers: List[str] = []
receiver_tasks: List[asyncio.Task] = [] receiver_tasks: List[asyncio.Task] = []
emitter_categories: Dict[str, str] = {} # ICAO hex -> ADS-B emitter category (A1, A3, A7, etc.)
@dataclass @dataclass
@@ -268,6 +269,7 @@ async def connect_to_receiver(host: str, port: int = 30003):
'vrate': 0, 'vrate': 0,
'squawk': '', 'squawk': '',
'ground': False, 'ground': False,
'category': emitter_categories.get(msg.icao.upper(), ''),
'last_seen': time.time() 'last_seen': time.time()
} }
@@ -345,6 +347,52 @@ async def test_port(ip: str, port: int, timeout: float = 0.5):
return False return False
async def poll_aircraft_json():
"""Poll dump1090/readsb JSON API to get ADS-B emitter categories.
The SBS/BaseStation protocol (port 30003) doesn't include emitter category,
but the dump1090 JSON API exposes the raw ADS-B category field (A1=Light,
A2=Small, A3=Large, A5=Heavy, A7=Rotorcraft, etc.). This task periodically
fetches that data and merges it into the flights dict.
"""
# Wait for receivers to be determined before starting
await asyncio.sleep(5)
json_api_urls = []
for receiver_ip in receivers:
json_api_urls.append(f"http://{receiver_ip}/dump1090/data/aircraft.json")
if not json_api_urls:
print("No receivers for JSON API polling")
return
timeout = ClientTimeout(total=5)
print(f"Starting JSON API polling for emitter categories: {json_api_urls}")
async with ClientSession(timeout=timeout) as session:
while True:
for url in json_api_urls:
try:
async with session.get(url) as resp:
if resp.status == 200:
data = await resp.json()
aircraft_list = data.get("aircraft", [])
count = 0
for ac in aircraft_list:
hex_code = ac.get("hex", "").strip().upper()
cat = ac.get("category", "")
if hex_code and cat:
emitter_categories[hex_code] = cat
# Merge into active flight if present
if hex_code in flights:
flights[hex_code]["category"] = cat
count += 1
except Exception:
pass # JSON API not available on this receiver, use heuristics
await asyncio.sleep(5)
async def cleanup_old_flights(): async def cleanup_old_flights():
"""Remove flights not seen recently (uses tuning config).""" """Remove flights not seen recently (uses tuning config)."""
while True: while True:
@@ -863,6 +911,7 @@ async def main():
start_http_server(), start_http_server(),
cleanup_old_flights(), cleanup_old_flights(),
broadcast_flights(), broadcast_flights(),
poll_aircraft_json(),
] ]
# Connect to all receivers # Connect to all receivers