diff --git a/csdr/chain/toolbox.py b/csdr/chain/toolbox.py index 5acc7628..b7125928 100644 --- a/csdr/chain/toolbox.py +++ b/csdr/chain/toolbox.py @@ -5,6 +5,7 @@ from pycsdr.types import Format from owrx.toolbox import TextParser, PageParser, SelCallParser, IsmParser from owrx.aircraft import HfdlParser, Vdl2Parser, AdsbParser, AcarsParser +import os class IsmDemodulator(ServiceDemodulator, DialFrequencyReceiver): def __init__(self, service: bool = False): @@ -145,12 +146,13 @@ class Vdl2Demodulator(ServiceDemodulator, DialFrequencyReceiver): class AdsbDemodulator(ServiceDemodulator, DialFrequencyReceiver): - def __init__(self, service: bool = False): + def __init__(self, service: bool = False, jsonFile: str = None): self.sampleRate = 2400000 - self.parser = AdsbParser(service=service) + self.parser = AdsbParser(service=service, jsonFile=jsonFile) + jsonFolder = os.path.dirname(jsonFile) if jsonFile else None workers = [ Convert(Format.COMPLEX_FLOAT, Format.COMPLEX_SHORT), - Dump1090Module(rawOutput = True, jsonOutput = True), + Dump1090Module(rawOutput = True, jsonFolder = jsonFolder), self.parser, ] # Connect all the workers diff --git a/csdr/module/toolbox.py b/csdr/module/toolbox.py index cb441be4..a8be115b 100644 --- a/csdr/module/toolbox.py +++ b/csdr/module/toolbox.py @@ -77,8 +77,8 @@ class DumpVdl2Module(PopenModule): class Dump1090Module(ExecModule): - def __init__(self, rawOutput: bool = False, jsonOutput: bool = False): - self.jsonFolder = "/tmp/dump1090" + def __init__(self, rawOutput: bool = False, jsonFolder: str = None): + self.jsonFolder = jsonFolder pm = Config.get() lat = pm["receiver_gps"]["lat"] lon = pm["receiver_gps"]["lon"] @@ -87,13 +87,16 @@ class Dump1090Module(ExecModule): "--lat", str(lat), "--lon", str(lon), "--modeac", "--metric" ] - if jsonOutput: + # If JSON files folder supplied, use that, disable STDOUT output + if self.jsonFolder is not None: try: os.makedirs(self.jsonFolder, exist_ok = True) - cmd += [ "--write-json", self.jsonFolder ] + cmd += [ "--quiet", "--write-json", self.jsonFolder ] except: + self.jsonFolder = None pass - if rawOutput: + # RAW STDOUT output only makes sense if we are not using JSON + if rawOutput and self.jsonFolder is None: cmd += [ "--raw" ] super().__init__(Format.COMPLEX_SHORT, Format.CHAR, cmd) diff --git a/htdocs/lib/MapMarkers.js b/htdocs/lib/MapMarkers.js index 453d0cb8..b600aeab 100644 --- a/htdocs/lib/MapMarkers.js +++ b/htdocs/lib/MapMarkers.js @@ -421,6 +421,8 @@ AprsMarker.prototype.update = function(update) { this.flight = update.location.flight; this.icao = update.location.icao; this.vspeed = update.location.vspeed; + this.squawk = update.location.squawk; + this.rssi = update.location.rssi; this.msglog = update.location.msglog; // Implementation-dependent function call @@ -608,6 +610,10 @@ AprsMarker.prototype.getInfoHTML = function(name, receiverMarker = null) { detailsString += Marker.makeListItem('Aircraft', Marker.linkify(this.aircraft, flight_url)); } + if (this.squawk) { + detailsString += Marker.makeListItem('Squawk', this.squawk); + } + if (this.origin) { detailsString += Marker.makeListItem('Origin', this.origin); } @@ -639,6 +645,10 @@ AprsMarker.prototype.getInfoHTML = function(name, receiverMarker = null) { detailsString += Marker.makeListItem('Altitude', alt); } + if (this.rssi) { + detailsString += Marker.makeListItem('RSSI', this.rssi + ' dB'); + } + if (detailsString.length > 0) { detailsString = '
' + Marker.makeListTitle('Details') + detailsString + '
'; } diff --git a/owrx/aircraft.py b/owrx/aircraft.py index 3964b5ce..30583f61 100644 --- a/owrx/aircraft.py +++ b/owrx/aircraft.py @@ -10,6 +10,7 @@ import json import math import time import re +import os import logging @@ -34,6 +35,37 @@ MODE_S_FORMATS = [ "Comm-D Message" ] +# +# Aircraft categories +# +ADSB_CATEGORIES = { + "A0": ("^", "/"), # No ADS-B emitter category information + "A1": ("'", "/"), # Light (< 15500 lbs) + "A2": ("'", "/"), # Small (15500 to 75000 lbs) + "A3": ("^", "/"), # Large (75000 to 300000 lbs) + "A4": ("^", "/"), # High vortex large (aircraft such as B-757) + "A5": ("^", "/"), # Heavy (> 300000 lbs) + "A6": ("^", "/"), # High performance (> 5g acceleration and 400 kts) + "A7": ("X", "/"), # Rotorcraft, regardless of weight + "B0": ("^", "/"), # No ADS-B emitter category information + "B1": ("g", "/"), # Glider or sailplane, regardless of weight + "B2": ("O", "/"), # Airship or balloon, regardless of weight + "B3": ("g", "/"), # Parachutist / skydiver + "B4": ("g", "/"), # Ultralight / hang-glider / paraglider + "B5": ("^", "/"), # Reserved + "B6": ("S", "\\"), # Unmanned aerial vehicle, regardless of weight + "B7": ("S", "/"), # Space / trans-atmospheric vehicle + "C0": ("D", "/"), # No ADS-B emitter category information + "C1": ("f", "/"), # Surface vehicle – emergency vehicle + "C2": ("u", "\\"), # Surface vehicle – service vehicle + "C3": ("D", "/"), # Point obstacle (includes tethered balloons) + "C4": ("D", "/"), # Cluster obstacle + "C5": ("D", "/"), # Line obstacle + "C6": ("D", "/"), # Reserved + "C7": ("D", "/"), # Reserved +} + + # # This class represents current aircraft location compatible with # the APRS markers. It can be used for displaying aircraft on the @@ -47,11 +79,17 @@ class AircraftLocation(LatLngLocation): def __dict__(self): res = super(AircraftLocation, self).__dict__() - # Add APRS-like aircraft symbol (red or blue, depending on mode) - mod = '/' if self.data["mode"]=="ADSB" else '\\' - res["symbol"] = getSymbolData('^', mod) + # Add an APRS-like symbol + if "category" in self.data and self.data["category"] in ADSB_CATEGORIES: + # Add APRS-like symbol by aircraft category + cat = ADSB_CATEGORIES[self.data["category"]] + res["symbol"] = getSymbolData(cat[0], cat[1]) + else: + # Add APRS-like aircraft symbol (red or blue, depending on mode) + mod = '/' if self.data["mode"]=="ADSB" else '\\' + res["symbol"] = getSymbolData('^', mod) # Convert aircraft-specific data into APRS-like data - for x in ["icao", "aircraft", "flight", "speed", "altitude", "course", "destination", "origin", "vspeed", "msglog"]: + for x in ["icao", "aircraft", "flight", "speed", "altitude", "course", "destination", "origin", "vspeed", "squawk", "rssi", "msglog"]: if x in self.data: res[x] = self.data[x] # Return APRS-like dictionary object @@ -66,11 +104,6 @@ class AircraftManager(object): sharedInstance = None creationLock = threading.Lock() - # Called on a timer - @staticmethod - def _periodicCleanup(arg): - arg.periodicCleanup() - # Return a global instance of the aircraft manager. @staticmethod def getSharedInstance(): @@ -103,16 +136,19 @@ class AircraftManager(object): def __init__(self): self.lock = threading.Lock() - self.checkTime = 60 + self.cleanupPeriod = 60 self.maxMsgLog = 20 self.colors = ColorCache() self.aircraft = {} - self.periodicCleanup() + # Start periodic cleanup task + self.thread = threading.Thread(target=self._cleanupThread) + self.thread.start() # Perform periodic cleanup - def periodicCleanup(self): - self.cleanup() - threading.Timer(self.checkTime, self._periodicCleanup, [self]).start() + def _cleanupThread(self): + while self.thread is not None: + time.sleep(self.cleanupPeriod) + self.cleanup() # Get aircraft data by ID. def getAircraft(self, id): @@ -453,17 +489,30 @@ class Vdl2Parser(AircraftParser): # Parser for ADSB messages coming from Dump1090 in hexadecimal format. # class AdsbParser(AircraftParser): - def __init__(self, service: bool = False): + def __init__(self, service: bool = False, jsonFile: str = None): super().__init__(filePrefix=None, service=service) self.smode_parser = ModeSParser() + self.jsonFile = jsonFile + self.checkPeriod = 1 + self.lastParse = 0 + # Start periodic JSON file check + if self.jsonFile is not None: + self.thread = threading.Thread(target=self._refreshThread) + self.thread.start() + # Default parsing function called by AircraftParser class def parseAircraft(self, msg: bytes): + # When using Dump1090 JSON file, do not parse here + if self.jsonFile is not None: + return None + # If it is a valid Mode-S message... if msg.startswith(b"*") and msg.endswith(b";") and len(msg) in [16, 30]: # Parse Mode-S message msg = msg[1:-1].decode('utf-8', 'replace') out = self.smode_parser.process(bytes.fromhex(msg)) #logger.debug("@@@ PARSE OUT: {0}".format(out)) + # Only consider position and identification reports for now if "identification" in out or "groundspeed" in out or ("lat" in out and "lon" in out): # Add fields for compatibility with other aircraft parsers @@ -508,7 +557,24 @@ class AdsbParser(AircraftParser): # No data parsed return None - def parseDump1090Json(self, file: str, updateDatabase: bool = False): + # Periodically check if Dump1090's JSON file has changed + # and parse it if it has. + def _refreshThread(self): + lastUpdate = 0 + while self.thread is not None: + # Wait until the next check + time.sleep(self.checkPeriod) + try: + # If JSON file has updated since the last update, parse it + ts = os.path.getmtime(self.jsonFile) + if ts > lastUpdate: + self.parseJson(self.jsonFile, updateDatabase=True) + lastUpdate = ts + except Exception as exptn: + logger.info("Failed to check file '{0}': {1}".format(self.jsonFile, exptn)) + + # Parse supplied JSON file in Dump1090 format. + def parseJson(self, file: str, updateDatabase: bool = False): # Load JSON from supplied file try: with open(file, "r") as f: @@ -519,56 +585,92 @@ class AdsbParser(AircraftParser): # Make sure we have the aircraft data if "aircraft" not in data or "now" not in data: return None - elif not updateDatabase: - # Return original JSON "aircraft" contents - return data["aircraft"] + + # The Dump1090 JSON data has a special mode + data["mode"] = "ADSB-LIST" + + # If not updating aircraft database, exit here + if not updateDatabase: + return data # Going to add timestamps and TTLs pm = Config.get() - ts = data["now"] - ttl = ts + pm["adsb_ttl"] - data = data["aircraft"] + now = data["now"] + ttl = now + pm["adsb_ttl"] # Iterate over aircraft - for entry in data: + for entry in data["aircraft"]: + # Do not update twice + ts = now - entry["seen"] + if ts <= self.lastParse: + continue + + # Always present ADSB data out = { "mode" : "ADSB", "icao" : entry["hex"].upper(), "ts" : ts, - "ttl" : ttl, + "ttl" : ttl - entry["seen"], "msgs" : entry["messages"], - "seen" : entry["seen"], "rssi" : entry["rssi"], } + # Position if "lat" in entry and "lon" in entry: out["lat"] = entry["lat"] out["lon"] = entry["lon"] + # Flight identification, aircraft type, squawk code if "flight" in entry: out["flight"] = entry["flight"].strip() - + if "category" in entry: + out["category"] = entry["category"] if "squawk" in entry: - out["type"] = "Squawk " + entry["squawk"] - elif "category" in entry: - out["type"] = "Category " + entry["category"] + out["squawk"] = entry["squawk"] + if "emergency" in entry and entry["emergency"] != "none": + out["emergency"] = entry["emergency"].upper() + # Altitude if "alt_geom" in entry: out["altitude"] = round(entry["alt_geom"] * METERS_PER_FOOT) elif "alt_baro" in entry: out["altitude"] = round(entry["alt_baro"] * METERS_PER_FOOT) - elif "nav_altitude_mcp" in entry: - out["altitude"] = round(entry["nav_altitude_mcp"] * METERS_PER_FOOT) - if "nav_heading" in entry: - out["course"] = round(entry["nav_heading"]) + # Climb/descent rate + if "geom_rate" in entry: + out["vspeed"] = round(entry["geom_rate"] * METERS_PER_FOOT) + elif "baro_rate" in entry: + out["vspeed"] = round(entry["baro_rate"] * METERS_PER_FOOT) + + # Speed + if "gs" in entry: + out["speed"] = round(entry["gs"] * KMH_PER_KNOT) + elif "tas" in entry: + out["speed"] = round(entry["tas"] * KMH_PER_KNOT) + elif "ias" in entry: + out["speed"] = round(entry["ias"] * KMH_PER_KNOT) + + # Heading + if "true_heading" in entry: + out["course"] = round(entry["true_heading"]) + elif "mag_heading" in entry: + out["course"] = round(entry["mag_heading"]) elif "track" in entry: out["course"] = round(entry["track"]) + # Outside temperature + if "oat" in entry: + out["temperature"] = entry["oat"] + elif "tat" in entry: + out["temperature"] = entry["tat"] + # Update aircraft database with the new data AircraftManager.getSharedInstance().update(out) - # Return original JSON "aircraft" contents + # Save last parsed time + self.lastParse = now + + # Return original JSON contents return data diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py index 9197efbe..938e57dc 100644 --- a/owrx/service/__init__.py +++ b/owrx/service/__init__.py @@ -342,6 +342,7 @@ class ServiceHandler(SdrSourceEventClient): elif mod == "adsb": from csdr.chain.toolbox import AdsbDemodulator return AdsbDemodulator(service=True) +# return AdsbDemodulator(service=True, jsonFile="/tmp/dump1090/aircraft.json") raise ValueError("unsupported service modulation: {}".format(mod))