openwebrxplus/owrx/aircraft.py

687 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from owrx.toolbox import TextParser, ColorCache
from owrx.map import Map, LatLngLocation
from owrx.aprs import getSymbolData
from owrx.config import Config
from datetime import datetime, timedelta
import threading
import pickle
import json
import math
import time
import re
import os
import logging
logger = logging.getLogger(__name__)
#
# Mode-S message formats
#
MODE_S_FORMATS = [
"Short ACAS", None, None, None,
"Altitude", "IDENT Reply", None, None,
None, None, None, "ADSB",
None, None, None, None,
"Long ACAS", "Extended ADSB", "Supplementary ADSB", "Exetended Military",
"Comm-B Altitude", "Comm-B IDENT Reply", "Military", None,
"Comm-D Message"
]
#
# Aircraft categories
#
ADSB_CATEGORIES = {
"A0": (0, 0), # No ADS-B emitter category information
"A1": (3, 0), # Light (< 15500 lbs)
"A2": (7, 6), # Small (15500 to 75000 lbs)
"A3": (5, 0), # Large (75000 to 300000 lbs)
"A4": (4, 0), # High vortex large (aircraft such as B-757)
"A5": (1, 7), # Heavy (> 300000 lbs)
"A6": (7, 0), # High performance (> 5g acceleration and 400 kts)
"A7": (6, 5), # Rotorcraft, regardless of weight
"B0": (0, 0), # No ADS-B emitter category information
"B1": (1, 6), # Glider or sailplane, regardless of weight
"B2": (2, 0), # Airship or balloon, regardless of weight
"B3": (10, 0), # Parachutist / skydiver
"B4": (10, 0), # Ultralight / hang-glider / paraglider
"B5": (0, 0), # Reserved
"B6": (4, 3), # Unmanned aerial vehicle, regardless of weight
"B7": (4, 5), # Space / trans-atmospheric vehicle
"C0": (4, 8), # No ADS-B emitter category information
"C1": (2, 8), # Surface vehicle emergency vehicle
"C2": (3, 8), # Surface vehicle service vehicle
"C3": (5, 8), # Point obstacle (includes tethered balloons)
"C4": (6, 9), # Cluster obstacle
"C5": (2, 8), # Line obstacle
"C6": (2, 8), # Reserved
"C7": (2, 8), # Reserved
}
MODE_CATEGORIES = {
"ADSB": (0, 0),
"ACARS": (5, 10),
"HFDL": (6, 10),
"VDL2": (7, 10),
}
#
# 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 getSymbol(self):
# Add an aircraft symbol
if "category" in self.data and self.data["category"] in ADSB_CATEGORIES:
# Add symbol by aircraft category
cat = ADSB_CATEGORIES[self.data["category"]]
return { "x": cat[0], "y": cat[1] }
elif "mode" in self.data and self.data["mode"] in MODE_CATEGORIES:
# Add symbol by comms moce (red, green, or blue)
cat = MODE_CATEGORIES[self.data["mode"]]
return { "x": cat[0], "y": cat[1] }
else:
# Default to white symbols
return { "x": 0, "y": 0 }
def __dict__(self):
res = super(AircraftLocation, self).__dict__()
# JavaScript timestamps are in milliseconds
res["ttl"] = self.data["ttl"] * 1000
res["symbol"] = self.getSymbol()
# Convert aircraft-specific data into APRS-like data
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
return res
#
# A global object of this class collects information on all
# currently reporting aircraft.
#
class AircraftManager(object):
sharedInstance = None
creationLock = threading.Lock()
# 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, in flight -> tail -> ICAO ID order.
@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.cleanupPeriod = 60
self.maxMsgLog = 20
self.colors = ColorCache()
self.aircraft = {}
# Start periodic cleanup task
self.thread = threading.Thread(target=self._cleanupThread)
self.thread.start()
# Perform periodic cleanup
def _cleanupThread(self):
while self.thread is not None:
time.sleep(self.cleanupPeriod)
self.cleanup()
# 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):
# Not updated yet
updated = False
# Identify aircraft the best we can, it MUST have some ID
id = self.getAircraftId(data)
if not id:
return updated
# Add time-to-live, if missing, assume HFDL longevity
if "ts" not in data:
pm = Config.get()
ts = datetime.now().timestamp()
data["ts"] = ts
data["ttl"] = ts + pm["hfdl_ttl"]
# Now operating on the database...
with self.lock:
# Merge database entries in flight -> tail -> ICAO ID order
if "icao" in data:
if "flight" in data:
self._merge(data["icao"], data["flight"])
if "aircraft" in data:
self._merge(data["icao"], data["aircraft"])
elif "aircraft" in data and "flight" in data:
self._merge(data["aircraft"], data["flight"])
# If no such ID yet...
if id not in self.aircraft:
logger.debug("Adding %s" % id)
# Create a new record
item = self.aircraft[id] = data.copy()
updated = True
else:
# Use existing record
item = self.aircraft[id]
# If we have got newer data...
if data["ts"] > item["ts"]:
# Get previous and current positions
pos0 = (item["lat"], item["lon"]) if "lat" in item and "lon" in item else None
pos1 = (data["lat"], data["lon"]) if "lat" in data and "lon" in data else None
# Update existing record
item.update(data)
updated = True
# If both current and previous positions exist, compute course
if "course" not in data and pos0 and pos1 and pos1 != pos0:
item["course"] = data["course"] = round(self.bearing(pos0, pos1))
#logger.debug("Updated %s course to %d degrees" % (id, item["course"]))
# Only if we have applied this update...
if updated:
# Add incoming messages to the log
if "message" in data:
if "msglog" not in item:
item["msglog"] = [ data["message"] ]
else:
msglog = item["msglog"]
msglog.append(data["message"])
if len(msglog) > self.maxMsgLog:
item["msglog"] = item["msglog"][-self.maxMsgLog:]
# 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"])
# Can later use this for linking to the map
data["mapid"] = id
# Update input data with computed data
for key in ["icao", "aircraft", "flight"]:
if key in item:
data[key] = item[key]
# Assign input data a color by its updated aircraft ID
data["color"] = self.colors.getColor(self.getAircraftId(data))
# Return TRUE if updated database
return updated
# Remove all database entries older than given time.
def cleanup(self):
now = datetime.now().timestamp()
# Now operating on the database...
with self.lock:
too_old = [x for x in self.aircraft.keys() if self.aircraft[x]["ttl"] < now]
if too_old:
logger.debug("Following aircraft have become stale: {0}".format(too_old))
for id in too_old:
self._removeFromMap(id)
del self.aircraft[id]
# Get current aircraft data reported in given mode
def getData(self, mode: str = None):
result = []
with self.lock:
for id in self.aircraft.keys():
item = self.aircraft[id]
# Ignore duplicates and data reported in different modes
if id == self.getAircraftId(item):
if not mode or mode == item["mode"]:
result.append(item)
return result
# Internal function to merge aircraft data
def _merge(self, id1, id2):
if id1 not in self.aircraft:
if id2 in self.aircraft:
logger.debug("Linking %s to %s" % (id1, id2))
self.aircraft[id1] = self.aircraft[id2]
elif id2 not in self.aircraft:
logger.debug("Linking %s to %s" % (id2, id1))
self.aircraft[id2] = self.aircraft[id1]
else:
item1 = self.aircraft[id1]
item2 = self.aircraft[id2]
if item1 is not item2:
# Make sure ID1 is always newer than ID2
if item1["ts"] < item2["ts"]:
item1, item2 = item2, item1
id1, id2 = id2, id1
# Update older data with newer data
logger.debug("Merging %s into %s" % (id2, id1))
item2.update(item1)
self.aircraft[id1] = item2
# Change ID2 color to ID1
self.colors.rename(id2, id1)
# Remove ID2 airplane from the map
self._removeFromMap(id2)
# 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 = None, service: bool = False):
self.reFlight = re.compile("^([0-9A-Z]{2}|[A-Z]{3})0*([0-9]+[A-Z]*)$")
self.reDots = re.compile("^\.*([^\.].*?)\.*$")
self.reIATA = re.compile("^..[0-9]+$")
super().__init__(filePrefix=filePrefix, service=service)
def parse(self, msg: bytes):
# Parse incoming message via mode-specific function
out = self.parseAircraft(msg)
if out is not None:
# Remove extra zeros from the flight ID
if "flight" in out:
out["flight"] = self.reFlight.sub("\\1\\2", out["flight"])
# Remove leading and trailing dots from ACARS data
for key in ["aircraft", "origin", "destination"]:
if key in out:
out[key] = self.reDots.sub("\\1", out[key])
# Update aircraft database with the new data
AircraftManager.getSharedInstance().update(out)
# Done
return out
# Mode-specific parse function
def parseAircraft(self, msg: bytes):
return None
# Common function to parse ACARS subframes in HFDL/VDL2/etc
def parseAcars(self, data, out):
# Collect data
out["type"] = "ACARS frame"
aircraft = data["reg"].strip()
message = data["msg_text"].strip()
flight = data["flight"].strip() if "flight" in data else ""
if len(aircraft)>0:
out["aircraft"] = aircraft
if len(message)>0:
out["message"] = [ message ]
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 parseAircraft(self, msg: bytes):
# Expect JSON data in text form
data = json.loads(msg)
pm = Config.get()
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,
"ttl" : ts + pm["hfdl_ttl"]
}
# 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)
# Done
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 parseAircraft(self, msg: bytes):
# Expect JSON data in text form
data = json.loads(msg)
pm = Config.get()
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,
"ttl" : ts + pm["vdl2_ttl"]
}
# Parse AVLC if present
if "avlc" in data["vdl2"]:
self.parseAvlc(data["vdl2"]["avlc"], out)
# Done
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"]
# Ignore dummy altitude value
alt = p["value"]["alt"]
if alt < 192000:
# Convert altitude from feet into meters
out["altitude"] = round(alt)
elif p["name"] == "dst_airport":
# Parse destination airport
out["destination"] = 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, jsonFile: str = "/tmp/dump1090/aircraft.json"):
super().__init__(filePrefix=None, service=service)
self.jsonFile = jsonFile
self.checkPeriod = 1
self.lastParse = 0
# Start periodic JSON file check
self.thread = threading.Thread(target=self._refreshThread)
self.thread.start()
# Not parsing STDOUT
def parseAircraft(self, msg: bytes):
return None
# 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:
lastUpdate = ts
parsed = self.parseJson(self.jsonFile)
if not self.service and parsed > 0:
data = AircraftManager.getSharedInstance().getData("ADSB")
self.writer.write(pickle.dumps({
"mode" : "ADSB-LIST",
"aircraft" : data
}))
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):
# Load JSON from supplied file
try:
with open(file, "r") as f:
data = f.read()
f.close()
data = json.loads(data)
except:
return 0
# Make sure we have the aircraft data
if "aircraft" not in data or "now" not in data:
return 0
# Going to add timestamps and TTLs
pm = Config.get()
now = data["now"]
ttl = now + pm["adsb_ttl"]
# Iterate over aircraft
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 - entry["seen"],
"msgs" : entry["messages"],
"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["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"])
elif "alt_baro" in entry:
out["altitude"] = round(entry["alt_baro"])
# Climb/descent rate
if "geom_rate" in entry:
out["vspeed"] = round(entry["geom_rate"])
elif "baro_rate" in entry:
out["vspeed"] = round(entry["baro_rate"])
# Speed
if "gs" in entry:
out["speed"] = round(entry["gs"])
elif "tas" in entry:
out["speed"] = round(entry["tas"])
elif "ias" in entry:
out["speed"] = round(entry["ias"])
# 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)
# Save last parsed time
self.lastParse = now
# Return the number of parsed records
return len(data["aircraft"])
#
# Parser for ACARS messages coming from AcarsDec in JSON format.
#
class AcarsParser(AircraftParser):
def __init__(self, service: bool = False):
super().__init__(filePrefix="ACARS", service=service)
self.attrMap = {
"tail" : "aircraft",
"flight" : "flight",
"text" : "message",
"dsta" : "destination",
"depa" : "origin",
"eta" : "eta",
}
def parseAircraft(self, msg: bytes):
# Expect JSON data in text form
data = json.loads(msg)
pm = Config.get()
ts = data["timestamp"]
#logger.debug("@@@ ACARS: {0}".format(data))
# Collect basic data first
out = {
"mode" : "ACARS",
"type" : "ACARS frame",
"time" : datetime.utcfromtimestamp(ts).strftime("%H:%M:%S"),
"ts" : ts,
"ttl" : ts + pm["acars_ttl"]
}
# Fetch other data
for key in self.attrMap:
if key in data:
value = data[key].strip()
if len(value)>0:
out[self.attrMap[key]] = value
# Done
return out