From a3fa38b74000100fdd68d42080feffca6628160f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 09:12:08 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 51 ++++++++++++++++++++++++------ ads-bit.js | 92 ++++++++++++++++++++++++++++++++++-------------------- server.py | 51 +++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef5ff54..94a1c17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/ads-bit.js b/ads-bit.js index d954b57..05bc955 100644 --- a/ads-bit.js +++ b/ads-bit.js @@ -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]; diff --git a/server.py b/server.py index e8a1fb7..1fb8c6c 100755 --- a/server.py +++ b/server.py @@ -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