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 c996ade142
commit a3fa38b740
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
- Reads configuration from config.json
- Auto-scans for ADS-B receivers or connects to configured IPs
- Parses SBS/BaseStation format messages from receivers
- Broadcasts flight data to connected WebSocket clients every 1 second
- Parses SBS/BaseStation format messages from receivers (port 30003)
- 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
- Serves static files, admin panel, setup wizard, and APIs
- 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
Priority order in categorization:
1. **Helicopter**: altitude < 5000 ft AND speed < 150 knots
2. **Heavy (747/A380)**: specific callsigns OR altitude > 42000 ft OR speed > 550 knots
3. **Wide Body**: altitude > 40000 ft OR speed > 500 knots
4. **Regional Jet**: specific callsigns OR (altitude < 25000 ft AND speed < 350 knots)
5. **Small Prop**: N-prefix callsigns OR (altitude < 10000 ft AND speed < 200 knots)
6. **Narrow Body**: default for remaining aircraft
Two-tier system: real ADS-B emitter categories when available, heuristic fallback otherwise.
#### Tier 1: ADS-B Emitter Category (from dump1090 JSON API)
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.
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
@@ -77,7 +107,8 @@ The viewer can rotate between cardinal directions (N/E/S/W), showing aircraft in
### External APIs
- **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
+59 -33
View File
@@ -749,9 +749,18 @@ class ADSBit {
flights.forEach(flight => {
if (flight.lat && flight.lon && flight.altitude) {
this.flights.set(flight.icao, flight);
// Categorize aircraft type - re-evaluate when speed data arrives
// since early messages often lack speed, leading to wrong guesses
if (!this.aircraftTypes.has(flight.icao) || (flight.speed > 0 && flight.callsign)) {
// Use ADS-B emitter category if available (from dump1090 JSON API),
// otherwise fall back to heuristics. Re-evaluate when new data arrives.
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);
}
}
@@ -759,15 +768,42 @@ class ADSBit {
// 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) {
// 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
//
// NOTE: speed=0 or altitude=0 means data not yet received, so we
// avoid making assumptions from missing data. The caller should
// re-categorize once better data arrives.
let category = 'narrowBody'; // Default
let type = 'narrowBody'; // Default
const callsign = (flight.callsign || '').trim();
const altitude = flight.altitude || 0;
@@ -793,67 +829,56 @@ class ADSBit {
// --- Apply rules ---
// 1. Helicopter: callsign match + reasonable flight envelope
// 1. Helicopter: callsign match
if (isHeliCallsign) {
category = 'helicopter';
type = 'helicopter';
}
// 2. Heavy / Wide body
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)) {
category = 'heavy';
type = 'heavy';
}
else if ((hasAlt && altitude > 40000) || (hasSpeed && speed > 500)) {
category = 'wideBody';
type = 'wideBody';
}
// 3. N-number = general aviation (small prop unless data says otherwise)
else if (isNNumber) {
// N-numbers at high altitude/speed are likely turboprops or light jets
if (hasAlt && altitude > 25000) {
category = 'narrowBody';
} else {
category = 'smallProp';
}
type = (hasAlt && altitude > 25000) ? 'narrowBody' : 'smallProp';
}
// 4. Regional jets
else if (regionalCallsigns.includes(airline)) {
category = 'regionalJet';
type = 'regionalJet';
}
// 5. Speed/altitude heuristics (only when we have real data)
else if (hasSpeed && hasAlt) {
if (speed < 60 && altitude < 5000) {
// Very slow + very low = likely helicopter
category = 'helicopter';
type = 'helicopter';
} else if (altitude < 18000 && speed < 300) {
// Below FL180 at moderate speed
if (speed < 170 && altitude < 12000) {
category = 'smallProp';
} else {
category = 'regionalJet';
}
type = (speed < 170 && altitude < 12000) ? 'smallProp' : 'regionalJet';
} else {
category = 'narrowBody';
type = 'narrowBody';
}
}
// 6. Altitude-only heuristic
else if (hasAlt && !hasSpeed) {
if (altitude < 10000) {
category = 'smallProp'; // Low altitude, unknown speed - guess GA
type = 'smallProp';
} else if (altitude < 25000) {
category = 'regionalJet';
type = 'regionalJet';
} else {
category = 'narrowBody';
type = 'narrowBody';
}
}
// 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) {
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;
}
@@ -1707,7 +1732,8 @@ class ADSBit {
}
// 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 imageLoaded = this.aircraftImagesLoaded[category];
+50 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Dict, Set, List
from aiohttp import web
from aiohttp import web, ClientSession, ClientTimeout
import netifaces
import bcrypt
@@ -166,6 +166,7 @@ flights: Dict[str, dict] = {}
connected_clients: Set = set()
receivers: List[str] = []
receiver_tasks: List[asyncio.Task] = []
emitter_categories: Dict[str, str] = {} # ICAO hex -> ADS-B emitter category (A1, A3, A7, etc.)
@dataclass
@@ -268,6 +269,7 @@ async def connect_to_receiver(host: str, port: int = 30003):
'vrate': 0,
'squawk': '',
'ground': False,
'category': emitter_categories.get(msg.icao.upper(), ''),
'last_seen': time.time()
}
@@ -345,6 +347,52 @@ async def test_port(ip: str, port: int, timeout: float = 0.5):
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():
"""Remove flights not seen recently (uses tuning config)."""
while True:
@@ -863,6 +911,7 @@ async def main():
start_http_server(),
cleanup_old_flights(),
broadcast_flights(),
poll_aircraft_json(),
]
# Connect to all receivers