410 lines
15 KiB
Python
410 lines
15 KiB
Python
from owrx.toolbox import TextParser, ColorCache
|
|
from owrx.map import Map, LatLngLocation
|
|
from owrx.aprs import getSymbolData
|
|
from owrx.adsb.modes import ModeSParser
|
|
from datetime import datetime, timedelta
|
|
import threading
|
|
import json
|
|
import math
|
|
import time
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#
|
|
# Feet per meter
|
|
#
|
|
FEET_PER_METER = 3.28084
|
|
KMH_PER_KNOT = 1.852
|
|
|
|
#
|
|
# This class represents current aircraft location compatible with
|
|
# the APRS markers. It can be used for displaying aircraft on the
|
|
# map.
|
|
#
|
|
class AircraftLocation(LatLngLocation):
|
|
def __init__(self, data):
|
|
super().__init__(data["lat"], data["lon"])
|
|
# Complete aircraft data
|
|
self.data = data
|
|
|
|
def __dict__(self):
|
|
res = super(AircraftLocation, self).__dict__()
|
|
# Add APRS-like aircraft symbol (red or blue, depending on mode)
|
|
mod = '/' if self.data["mode"]=="HFDL" else '\\'
|
|
res["symbol"] = getSymbolData('^', mod)
|
|
# Convert aircraft-specific data into APRS-like data
|
|
for x in ["icao", "aircraft", "flight", "speed", "altitude", "course", "airport", "vspeed"]:
|
|
if x in self.data:
|
|
res[x] = self.data[x]
|
|
# Treat last message as comment
|
|
if "message" in self.data:
|
|
res["comment"] = self.data["message"]
|
|
# Return APRS-like dictionary object
|
|
return res
|
|
|
|
|
|
#
|
|
# A global object of this class collects information on all
|
|
# currently reporting aircraft.
|
|
#
|
|
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():
|
|
with AircraftManager.creationLock:
|
|
if AircraftManager.sharedInstance is None:
|
|
AircraftManager.sharedInstance = AircraftManager()
|
|
return AircraftManager.sharedInstance
|
|
|
|
# Get unique aircraft ID, using ICAO (ModeS), tail, or flight number.
|
|
@staticmethod
|
|
def getAircraftId(data):
|
|
if "icao" in data:
|
|
return data["icao"]
|
|
elif "aircraft" in data:
|
|
return data["aircraft"]
|
|
elif "flight" in data:
|
|
return data["flight"]
|
|
else:
|
|
return None
|
|
|
|
# Compute bearing (in degrees) between two latlons.
|
|
@staticmethod
|
|
def bearing(p1, p2):
|
|
d = (p2[1] - p1[1]) * math.pi / 180
|
|
pr1 = p1[0] * math.pi / 180
|
|
pr2 = p2[0] * math.pi / 180
|
|
y = math.sin(d) * math.cos(pr2)
|
|
x = math.cos(pr1) * math.sin(pr2) - math.sin(pr1) * math.cos(pr2) * math.cos(d)
|
|
return (math.atan2(y, x) * 180 / math.pi + 360) % 360
|
|
|
|
def __init__(self):
|
|
self.lock = threading.Lock()
|
|
self.retainTime = 60*60
|
|
self.checkTime = 60
|
|
self.colors = ColorCache()
|
|
self.aircraft = {}
|
|
self.periodicCleanup()
|
|
|
|
# Perform periodic cleanup
|
|
def periodicCleanup(self):
|
|
self.cleanup(datetime.utcnow() - timedelta(seconds=self.retainTime))
|
|
threading.Timer(self.checkTime, self._periodicCleanup, [self]).start()
|
|
|
|
# Get aircraft data by ID.
|
|
def getAircraft(self, id):
|
|
return self.aircraft[id] if id in self.aircraft else {}
|
|
|
|
# Add a new aircraft to the database, or update existing aircraft data.
|
|
def update(self, data):
|
|
# Identify aircraft the best we can, it MUST have some ID
|
|
id = self.getAircraftId(data)
|
|
if not id:
|
|
return
|
|
# Now operating on the database...
|
|
with self.lock:
|
|
# If no such ID yet, see if we know this aircraft by other IDs
|
|
if id not in self.aircraft:
|
|
# Replace flight ID with better ID
|
|
if "flight" in data and data["flight"] in self.aircraft:
|
|
old_id = data["flight"]
|
|
self.aircraft[id] = self.aircraft[old_id]
|
|
self.colors.rename(old_id, id)
|
|
self._removeFromMap(old_id)
|
|
del self.aircraft[old_id]
|
|
# Replace aircraft ID with better ID
|
|
elif "aircraft" in data and data["aircraft"] in self.aircraft:
|
|
old_id = data["aircraft"]
|
|
self.aircraft[id] = self.aircraft[old_id]
|
|
self.colors.rename(old_id, id)
|
|
self._removeFromMap(old_id)
|
|
del self.aircraft[old_id]
|
|
# If still no ID in the database...
|
|
if id not in self.aircraft:
|
|
# Create a new record
|
|
self.aircraft[id] = data.copy()
|
|
item = self.aircraft[id]
|
|
else:
|
|
# Previous data and position
|
|
item = self.aircraft[id]
|
|
pos0 = (item["lat"], item["lon"]) if "lat" in item and "lon" in item else None
|
|
# Current data and position
|
|
item.update(data)
|
|
pos1 = (item["lat"], item["lon"]) if "lat" in item and "lon" in item else None
|
|
# If both positions exist, compute course
|
|
if "course" not in data and pos0 and pos1 and pos1 != pos0:
|
|
item["course"] = round(self.bearing(pos0, pos1))
|
|
# Update timestamp, if missing
|
|
if "ts" not in data:
|
|
item["ts"] = datetime.utcnow().timestamp()
|
|
# Update aircraft on the map
|
|
if "lat" in item and "lon" in item and "mode" in item:
|
|
loc = AircraftLocation(item)
|
|
Map.getSharedInstance().updateLocation(id, loc, item["mode"])
|
|
# Update input data with computed data
|
|
for key in ["icao", "aircraft", "flight", "course", "ts"]:
|
|
if key in item:
|
|
data[key] = item[key]
|
|
# Assign a color by ID
|
|
data["color"] = self.colors.getColor(id)
|
|
|
|
# Remove all database entries older than given time.
|
|
def cleanup(self, horizon):
|
|
horizon = horizon.timestamp()
|
|
# Now operating on the database...
|
|
with self.lock:
|
|
too_old = [x for x in self.aircraft.keys() if self.aircraft[x]["ts"] <= horizon]
|
|
for id in too_old:
|
|
self._removeFromMap(id)
|
|
del self.aircraft[id]
|
|
|
|
# Internal function to remove aircraft from the map
|
|
def _removeFromMap(self, id):
|
|
# Ignore errors removing non-existing flights
|
|
try:
|
|
item = self.aircraft[id]
|
|
if "lat" in item and "lon" in item:
|
|
Map.getSharedInstance().removeLocation(id)
|
|
except Exception as exptn:
|
|
logger.debug("Exception removing aircraft %s: %s" % (id, str(exptn)))
|
|
|
|
|
|
#
|
|
# Base class for aircraft message parsers.
|
|
#
|
|
class AircraftParser(TextParser):
|
|
def __init__(self, filePrefix: str, service: bool = False):
|
|
super().__init__(filePrefix=filePrefix, service=service)
|
|
|
|
def parseAcars(self, data, out):
|
|
# Collect data
|
|
out["type"] = "ACARS frame"
|
|
out["aircraft"] = data["reg"].strip()
|
|
out["message"] = data["msg_text"].strip()
|
|
# Get flight ID, if present
|
|
flight = data["flight"].strip() if "flight" in data else ""
|
|
if len(flight)>0:
|
|
out["flight"] = flight
|
|
# Done
|
|
return out
|
|
|
|
|
|
#
|
|
# Parser for HFDL messages coming from DumpHFDL in JSON format.
|
|
#
|
|
class HfdlParser(AircraftParser):
|
|
def __init__(self, service: bool = False):
|
|
super().__init__(filePrefix="HFDL", service=service)
|
|
|
|
def parse(self, msg: str):
|
|
# Expect JSON data in text form
|
|
data = json.loads(msg)
|
|
ts = data["hfdl"]["t"]["sec"] + data["hfdl"]["t"]["usec"] / 1000000
|
|
# @@@ Only parse messages that have LDPU frames for now !!!
|
|
if "lpdu" not in data["hfdl"]:
|
|
return {}
|
|
# Collect basic data first
|
|
out = {
|
|
"mode" : "HFDL",
|
|
"time" : datetime.utcfromtimestamp(ts).strftime("%H:%M:%S"),
|
|
"ts" : ts
|
|
}
|
|
# Parse LPDU if present
|
|
if "lpdu" in data["hfdl"]:
|
|
self.parseLpdu(data["hfdl"]["lpdu"], out)
|
|
# Parse SPDU if present
|
|
if "spdu" in data["hfdl"]:
|
|
self.parseSpdu(data["hfdl"]["spdu"], out)
|
|
# Parse MPDU if present
|
|
if "mpdu" in data["hfdl"]:
|
|
self.parseMpdu(data["hfdl"]["mpdu"], out)
|
|
# Update aircraft database with the new data
|
|
AircraftManager.getSharedInstance().update(out)
|
|
return out
|
|
|
|
def parseSpdu(self, data, out):
|
|
# Not parsing yet
|
|
out["type"] = "SPDU frame"
|
|
return out
|
|
|
|
def parseMpdu(self, data, out):
|
|
# Not parsing yet
|
|
out["type"] = "MPDU frame"
|
|
return out
|
|
|
|
def parseLpdu(self, data, out):
|
|
# Collect data
|
|
out["type"] = data["type"]["name"]
|
|
# Add aircraft info, if present, assign color right away
|
|
if "ac_info" in data and "icao" in data["ac_info"]:
|
|
out["icao"] = data["ac_info"]["icao"].strip()
|
|
# Source might be a ground station
|
|
#if data["src"]["type"] == "Ground station":
|
|
# out["flight"] = "GS-%d" % data["src"]["id"]
|
|
# Parse HFNPDU is present
|
|
if "hfnpdu" in data:
|
|
self.parseHfnpdu(data["hfnpdu"], out)
|
|
# Done
|
|
return out
|
|
|
|
def parseHfnpdu(self, data, out):
|
|
# Use flight ID as unique identifier
|
|
flight = data["flight_id"].strip() if "flight_id" in data else ""
|
|
if len(flight)>0:
|
|
out["flight"] = flight
|
|
# If we see ACARS message, parse it and drop out
|
|
if "acars" in data:
|
|
return self.parseAcars(data["acars"], out)
|
|
# If message carries time, parse it
|
|
if "utc_time" in data:
|
|
msgtime = data["utc_time"]
|
|
elif "time" in data:
|
|
msgtime = data["time"]
|
|
else:
|
|
msgtime = None
|
|
# Add reported message time, if present
|
|
if msgtime:
|
|
out["msgtime"] = "%02d:%02d:%02d" % (
|
|
msgtime["hour"], msgtime["min"], msgtime["sec"]
|
|
)
|
|
# Add aircraft location, if present
|
|
if "pos" in data:
|
|
out["lat"] = data["pos"]["lat"]
|
|
out["lon"] = data["pos"]["lon"]
|
|
# Done
|
|
return out
|
|
|
|
|
|
#
|
|
# Parser for VDL2 messages coming from DumpVDL2 in JSON format.
|
|
#
|
|
class Vdl2Parser(AircraftParser):
|
|
def __init__(self, service: bool = False):
|
|
super().__init__(filePrefix="VDL2", service=service)
|
|
|
|
def parse(self, msg: str):
|
|
# Expect JSON data in text form
|
|
data = json.loads(msg)
|
|
ts = data["vdl2"]["t"]["sec"] + data["vdl2"]["t"]["usec"] / 1000000
|
|
# Collect basic data first
|
|
out = {
|
|
"mode" : "VDL2",
|
|
"time" : datetime.utcfromtimestamp(ts).strftime("%H:%M:%S"),
|
|
"ts" : ts
|
|
}
|
|
# Parse AVLC if present
|
|
if "avlc" in data["vdl2"]:
|
|
self.parseAvlc(data["vdl2"]["avlc"], out)
|
|
# Update aircraft database with the new data
|
|
AircraftManager.getSharedInstance().update(out)
|
|
return out
|
|
|
|
def parseAvlc(self, data, out):
|
|
# Find if aircraft is message's source or destination
|
|
if data["src"]["type"] == "Aircraft":
|
|
p = data["src"]
|
|
elif data["dst"]["type"] == "Aircraft":
|
|
p = data["dst"]
|
|
else:
|
|
return out
|
|
# Address is the ICAO ID
|
|
out["icao"] = p["addr"]
|
|
# Clarify message type as much as possible
|
|
if "status" in p:
|
|
out["type"] = p["status"]
|
|
if "cmd" in data:
|
|
if "type" in out:
|
|
out["type"] += ", " + data["cmd"]
|
|
else:
|
|
out["type"] = data["cmd"]
|
|
# Parse ACARS if present
|
|
if "acars" in data:
|
|
self.parseAcars(data["acars"], out)
|
|
# Parse XID if present
|
|
if "xid" in data:
|
|
self.parseXid(data["xid"], out)
|
|
# Done
|
|
return out
|
|
|
|
def parseXid(self, data, out):
|
|
# Collect data
|
|
out["type"] = "XID " + data["type_descr"]
|
|
if "vdl_params" in data:
|
|
# Parse VDL parameters array
|
|
for p in data["vdl_params"]:
|
|
if p["name"] == "ac_location":
|
|
# Parse location
|
|
out["lat"] = p["value"]["loc"]["lat"]
|
|
out["lon"] = p["value"]["loc"]["lon"]
|
|
# Convert altitude from feet into meters
|
|
out["altitude"] = round(p["value"]["alt"] / FEET_PER_METER)
|
|
elif p["name"] == "dst_airport":
|
|
# Parse destination airport
|
|
out["airport"] = p["value"]
|
|
elif p["name"] == "modulation_support":
|
|
# Parse supported modulations
|
|
out["modes"] = p["value"]
|
|
# Done
|
|
return out
|
|
|
|
|
|
#
|
|
# Parser for ADSB messages coming from Dump1090 in hexadecimal format.
|
|
#
|
|
class AdsbParser(AircraftParser):
|
|
def __init__(self, service: bool = False):
|
|
super().__init__(filePrefix="ADSB", service=service)
|
|
self.smode_parser = ModeSParser()
|
|
|
|
def parse(self, msg: str):
|
|
# If it is a valid Mode-S message...
|
|
if msg.startswith("*") and msg.endswith(";") and len(msg) in [16, 30]:
|
|
# Parse Mode-S message
|
|
out = self.smode_parser.process(bytes.fromhex(msg[1:-1]))
|
|
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
|
|
now = datetime.utcnow()
|
|
out["ts"] = now.timestamp()
|
|
out["time"] = now.strftime("%H:%M:%S")
|
|
out["icao"] = out["icao"].upper()
|
|
out["type"] = "ADSB Format {0} frame".format(out["format"])
|
|
# Flight ID, if present
|
|
if "identification" in out:
|
|
out["flight"] = out["identification"].strip()
|
|
# Altitude, if present
|
|
if "altitude" in out:
|
|
out["altitude"] = round(out["altitude"] / FEET_PER_METER)
|
|
# Vertical speed, if present
|
|
if "verticalspeed" in out:
|
|
out["vspeed"] = round(out["verticalspeed"] / FEET_PER_METER)
|
|
# Speed, if present
|
|
if "groundspeed" in out:
|
|
out["speed"] = round(out["groundspeed"] * KMH_PER_KNOT)
|
|
#elif "TAS" in out:
|
|
# out["speed"] = round(out["TAS"] * KMH_PER_KNOT)
|
|
#elif "IAS" in out:
|
|
# out["speed"] = round(out["IAS"] * KMH_PER_KNOT)
|
|
# Course, if present (prefer actual aircraft orientation)
|
|
if "heading" in out:
|
|
out["course"] = round(out["heading"])
|
|
elif "groundtrack" in out:
|
|
out["course"] = round(out["groundtrack"])
|
|
# Update aircraft database with the new data
|
|
AircraftManager.getSharedInstance().update(out)
|
|
return out
|
|
|
|
# No data parsed
|
|
return {}
|