01234567890123456789012345678901234567890123456789012345678901234567890123456789
1234567891011 121314151617181920212223242526272829303132333435363738394041424344 4546474849505152535455565758596061626364656667686970717273 7475767778798081828384 858687888990919293949596979899100101102103104 125126127128129130131132133134135136137138139140141142143144 145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 176177178 179180 181182183184185186187188189190191 192193194195 196197198199200201202203204205206207208 209210211 212213214215216217218219220 221222223224225 226227228229230231232233234235236237238239240241242243244245 248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 318319320321322323324325326327328329330331332333334335336337338339340341342343344 787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834 1013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043 104410451046 104710481049 1050 105110521053 10541055105610571058105910601061 10621063106410651066106710681069107010711072107310741075107610771078107910801081 10831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114 111511161117 111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192 119311941195119611971198119912001201120212031204120512061207120812091210 121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246 1247124812491250125112521253 125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289 12901291129212931294 12951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320 1321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360 20602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103 32703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310 4460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490 449144924493 449444954496449744984499450045014502450345044505450645074508450945104511451245134514 46644665466646674668466946704671467246734674467546764677467846794680468146824683 46844685468646874688468946904691469246934694469546964697469846994700470147024703 480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856 506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112 51135114511551165117511851195120 5121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151 5152515351545155515651575158515951605161 51625163516451655166516751685169 517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214 5215 5216521752185219 5220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247 5248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275 527652775278527952805281528252835284528552865287528852895290529152925293529452955296 533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383 | #!/usr/bin/python3 import datetime import io import json import math import multiprocessing import numbers import os import pickle import queue import shutil import signal import statistics import sys import textwrap import time import bs4 import dateutil.relativedelta import filelock import numpy import matplotlib import matplotlib.pyplot import psutil import pycurl import pytz import requests import tzlocal import unidecode # This is the directory that stores all the ancillary messageboard configuration files # that do not need to be exposed via the webserver MESSAGEBOARD_PATH = '/home/pi/splitflap/' # This is the directory of the webserver; files placed here are available at # http://adsbx-custom.local/; files placed in this directly are visible via a browser WEBSERVER_PATH = '/var/www/html/' VERBOSE = False # additional messages logged RASPBERRY_PI = psutil.sys.platform.title() == 'Linux' if RASPBERRY_PI: import gpiozero # pylint: disable=E0401 import RPi.GPIO # pylint: disable=E0401 SHUTDOWN_SIGNAL = False REBOOT_SIGNAL = False SIMULATION = False SIMULATION_COUNTER = 0 SIMULATION_PREFIX = 'SIM_' PICKLE_DUMP_JSON_FILE = 'pickle/dump_json.pk' PICKLE_FA_JSON_FILE = 'pickle/fa_json.pk' DUMP_JSONS = None # loaded only if in simulation mode FA_JSONS = None # loaded only if in simulation mode HOME_LAT = 37.64406 HOME_LON = -122.43463 HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna HOME_ALT = 29 #altitude in meters RADIUS = 6371.0e3 # radius of earth in meters FEET_IN_METER = 3.28084 FEET_IN_MILE = 5280 METERS_PER_SECOND_IN_KNOTS = 0.514444 MIN_METERS = 5000/FEET_IN_METER # only planes within this distance will be detailed # planes not seen within MIN_METERS in PERSISTENCE_SECONDS seconds will be dropped from # the nearby list PERSISTENCE_SECONDS = 10 TRUNCATE = 50 # max number of keys to include in a histogram image file # number of seconds to pause between each radio poll / command processing loop LOOP_DELAY_SECONDS = 1 MAX_INSIGHT_HORIZON_DAYS = 30 # This file is where the radio drops its json file DUMP_JSON_FILE = '/run/readsb/aircraft.json' # At the time a flight is first identified as being of interest (in that it falls # within MIN_METERS meters of HOME), it - and core attributes derived from FlightAware, # if any - is appended to the end of this pickle file. However, since this file is # cached in working memory, flights older than 30 days are flushed from this periodically. PICKLE_FLIGHTS = 'pickle/flights.pk' CACHED_ELEMENT_PREFIX = 'cached_' # This web-exposed file is used for non-error messages that might highlight data or # code logic to check into. It is only cleared out manually. LOGFILE = 'log.txt' LOGFILE_LOCK = 'log.txt.lock' # Identical to the LOGFILE, except it includes just the most recent n lines. Newest # lines are at the end. ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages # Users can trigger .png histograms analogous to the text ones from the web interface; # this is the folder (within WEBSERVER_PATH) where those files are placed WEBSERVER_IMAGE_FOLDER = 'images/' # Multiple histograms can be generated, i.e. for airline, aircraft, day of week, etc. # The output files are named by the prefix & suffix, i.e.: prefix + type + . + suffix, # as in histogram_aircraft.png. These names match up to the names expected by the html # page that displays the images. Also, note that the suffix is interpreted by matplotlib # to identify the image format to create. HISTOGRAM_IMAGE_PREFIX = 'histogram_' <----SKIPPED LINES----> # away we anticipate the flight being at its closest point to HOME. As those two # parameters are manipulated in the settings, a histogram is displayed with one or # potentially two series, showing the present and potentially prior-set distribution # of flights, by hour throughout the day, over the last seven days, normalized to # flights per day. This allows those parameters to be fine-tuned in a useful way. # This file is the location, on the webserver, of that image, which needs to be in # alignment with the html page that displays it. HOURLY_IMAGE_FILE = 'hours.png' # This is all messages that have been sent to the board since the last time the # file was manually cleared. Newest messages are at the bottom. It is visible at the # webserver. ALL_MESSAGE_FILE = 'all_messages.txt' #enumeration of all messages sent to board # This shows the most recent n messages sent to the board. Newest messages are at the # top for easier viewing of "what did I miss". ROLLING_MESSAGE_FILE = 'rolling_messages.txt' STDERR_FILE = 'stderr.txt' BACKUP_FILE = 'backup.txt' SERVICE_VERIFICATION_FILE = 'service-verification.txt' FLAG_MSG_FLIGHT = 1 # basic flight details FLAG_MSG_INTERESTING = 2 # random tidbit about a flight FLAG_MSG_HISTOGRAM = 3 # histogram message FLAG_INSIGHT_LAST_SEEN = 0 FLAG_INSIGHT_DIFF_AIRCRAFT = 1 FLAG_INSIGHT_NTH_FLIGHT = 2 FLAG_INSIGHT_GROUNDSPEED = 3 FLAG_INSIGHT_ALTITUDE = 4 FLAG_INSIGHT_VERTRATE = 5 FLAG_INSIGHT_FIRST_DEST = 6 FLAG_INSIGHT_FIRST_ORIGIN = 7 FLAG_INSIGHT_FIRST_AIRLINE = 8 FLAG_INSIGHT_FIRST_AIRCRAFT = 9 FLAG_INSIGHT_LONGEST_DELAY = 10 FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY = 11 FLAG_INSIGHT_FLIGHT_DELAY_TIME = 12 FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY = 13 FLAG_INSIGHT_AIRLINE_DELAY_TIME = 14 FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY = 15 FLAG_INSIGHT_DESTINATION_DELAY_TIME = 16 FLAG_INSIGHT_HOUR_DELAY_FREQUENCY = 17 FLAG_INSIGHT_HOUR_DELAY_TIME = 18 FLAG_INSIGHT_DATE_DELAY_FREQUENCY = 19 FLAG_INSIGHT_DATE_DELAY_TIME = 20 INSIGHT_TYPES = 21 TEMP_FAN_TURN_ON_CELSIUS = 65 TEMP_FAN_TURN_OFF_CELSIUS = 55 # GPIO relay connections - (GPIO pin, true message, false message, relay number) GPIO_ERROR_VESTABOARD_CONNECTION = ( 22, 'Vestaboard unavailable', 'Vestaboard available', 1) GPIO_ERROR_FLIGHT_AWARE_CONNECTION = ( 23, 'FlightAware not available', 'FlightAware available', 2) GPIO_ERROR_ARDUINO_SERVO_CONNECTION = ( 24, 'Servos not running or lost connection', 'Handshake with servo Arduino received', 3) GPIO_ERROR_ARDUINO_REMOTE_CONNECTION = ( 25, 'Remote not running or lost connection', 'Handshake with remote Arduino received', 4) GPIO_ERROR_BATTERY_CHARGE = (26, 'Remote battery is low', 'Remote battery recharged', 5) GPIO_FAN = ( 5, 'RPi above %dC degrees; fan switched on' % TEMP_FAN_TURN_ON_CELSIUS, 'RPi below %dC degrees; fan switched off' % TEMP_FAN_TURN_OFF_CELSIUS, 7) # for future expansion GPIO_UNUSED_1 = ( 27, 'Undefined condition set to true', 'Undefined condition set to false', 6) GPIO_UNUSED_2 = ( 6, 'Undefined condition set to true', 'Undefined condition set to false', 8) # GPIO pushbutton connections - (GPIO pin switch in; GPIO pin LED out) GPIO_SOFT_RESET = (20, 21) #if running on raspberry, then need to prepend path to file names if RASPBERRY_PI: PICKLE_FLIGHTS = MESSAGEBOARD_PATH + PICKLE_FLIGHTS LOGFILE = MESSAGEBOARD_PATH + LOGFILE PICKLE_DUMP_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_DUMP_JSON_FILE PICKLE_FA_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_FA_JSON_FILE HISTOGRAM_CONFIG_FILE = WEBSERVER_PATH + HISTOGRAM_CONFIG_FILE CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE STDERR_FILE = WEBSERVER_PATH + STDERR_FILE BACKUP_FILE = WEBSERVER_PATH + BACKUP_FILE SERVICE_VERIFICATION_FILE = WEBSERVER_PATH + SERVICE_VERIFICATION_FILE HISTOGRAM_IMAGE_PREFIX = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX HISTOGRAM_EMPTY_IMAGE_FILE = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE) HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE TIMEZONE = 'US/Pacific' # timezone of display TZ = pytz.timezone(TIMEZONE) KNOWN_AIRPORTS = ('SJC', 'SFO', 'OAK') # iata codes that we don't need to expand SPLITFLAP_CHARS_PER_LINE = 22 SPLITFLAP_LINE_COUNT = 6 DIRECTIONS_4 = ['N', 'E', 'S', 'W'] DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a', ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p', ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p'] SECONDS_IN_MINUTE = 60 MINUTES_IN_HOUR = 60 <----SKIPPED LINES----> MINUTES_IN_DAY = MINUTES_IN_HOUR * HOURS_IN_DAY SECONDS_IN_DAY = SECONDS_IN_HOUR * HOURS_IN_DAY # Units confirmed here: # www.adsbexchange.com/forum/threads/units-in-the-dump1090-json-file.630617/#post-639541 CLIMB_RATE_UNITS = 'fpm' #speed units from tracker are knots, based on dump-1090/track.c #https://github.com/SDRplay/dump1090/blob/master/track.c SPEED_UNITS = 'kn' DISTANCE_UNITS = 'ft' # altitude # For displaying histograms # If a key is not present, how should it be displayed in histograms? KEY_NOT_PRESENT_STRING = 'Unknown' OTHER_STRING = 'Other' # What key strings should be listed last in sequence? # What key strings should be listed last in sequence? SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING] # What is the sorted sequence of keys for days of week? DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] import arduino # module expects paths to be set before import # pylint: disable=C0413 aircraft_length = {} # in meters aircraft_length['Airbus A220-100 (twin-jet)'] = 35 aircraft_length['Airbus A300F4-600 (twin-jet)'] = 54.08 aircraft_length['Airbus A319 (twin-jet)'] = 33.84 aircraft_length['Airbus A320 (twin-jet)'] = 37.57 aircraft_length['Airbus A320neo (twin-jet)'] = 37.57 aircraft_length['Airbus A321 (twin-jet)'] = 44.51 aircraft_length['Airbus A321neo (twin-jet)'] = 44.51 aircraft_length['Airbus A330-200 (twin-jet)'] = 58.82 aircraft_length['Airbus A330-300 (twin-jet)'] = 63.67 aircraft_length['Airbus A340-300 (quad-jet)'] = 63.69 aircraft_length['Airbus A350-1000 (twin-jet)'] = 73.79 aircraft_length['Airbus A350-900 (twin-jet)'] = 66.8 aircraft_length['Airbus A380-800 (quad-jet)'] = 72.72 aircraft_length['Boeing 737-400 (twin-jet)'] = 36.4 aircraft_length['Boeing 737-700 (twin-jet)'] = 33.63 aircraft_length['Boeing 737-800 (twin-jet)'] = 39.47 aircraft_length['Boeing 737-900 (twin-jet)'] = 42.11 aircraft_length['Boeing 747-400 (quad-jet)'] = 36.4 aircraft_length['Boeing 747-8 (quad-jet)'] = 76.25 aircraft_length['Boeing 757-200 (twin-jet)'] = 47.3 aircraft_length['Boeing 757-300 (twin-jet)'] = 54.4 aircraft_length['Boeing 767-200 (twin-jet)'] = 48.51 aircraft_length['Boeing 767-300 (twin-jet)'] = 54.94 aircraft_length['Boeing 777 (twin-jet)'] = (63.73 + 73.86) / 2 aircraft_length['Boeing 777-200 (twin-jet)'] = 63.73 aircraft_length['Boeing 777-200LR/F (twin-jet)'] = 63.73 aircraft_length['Boeing 777-300ER (twin-jet)'] = 73.86 aircraft_length['Boeing 787-10 (twin-jet)'] = 68.28 aircraft_length['Boeing 787-8 (twin-jet)'] = 56.72 aircraft_length['Boeing 787-9 (twin-jet)'] = 62.81 aircraft_length['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77 aircraft_length['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3 aircraft_length['Canadair Regional Jet CRJ-900 (twin-jet)'] = 36.2 aircraft_length['Canadair Challenger 350 (twin-jet)'] = 20.9 aircraft_length['Bombardier Challenger 300 (twin-jet)'] = 20.92 aircraft_length['Embraer 170/175 (twin-jet)'] = (29.90 + 31.68) / 2 aircraft_length['Embraer Phenom 300 (twin-jet)'] = 15.9 aircraft_length['EMBRAER 175 (long wing) (twin-jet)'] = 31.68 aircraft_length['Embraer ERJ-135 (twin-jet)'] = 26.33 aircraft_length['Cessna Caravan (single-turboprop)'] = 11.46 aircraft_length['Cessna Citation CJ2+ (twin-jet)'] = 14.53 aircraft_length['Cessna Citation II (twin-jet)'] = 14.54 aircraft_length['Cessna Citation V (twin-jet)'] = 14.91 aircraft_length['Cessna Citation X (twin-jet)'] = 22.04 aircraft_length['Cessna Skyhawk (piston-single)'] = 8.28 aircraft_length['Cessna Skylane (piston-single)'] = 8.84 aircraft_length['Cessna Citation Sovereign (twin-jet)'] = 19.35 aircraft_length['Cessna T206 Turbo Stationair (piston-single)'] = 8.61 aircraft_length['Beechcraft Bonanza (33) (piston-single)'] = 7.65 aircraft_length['Beechcraft Super King Air 200 (twin-turboprop)'] = 13.31 aircraft_length['Beechcraft Super King Air 350 (twin-turboprop)'] = 14.22 aircraft_length['Beechcraft King Air 90 (twin-turboprop)'] = 10.82 aircraft_length['Learjet 45 (twin-jet)'] = 17.68 aircraft_length['Pilatus PC-12 (single-turboprop)'] = 14.4 def Log(message, file=None, rolling=None): """Write a message to a logfile along with a timestamp. Args: message: string message to write file: string representing file name and, if needed, path to the file to write to rolling: name of file that will keep only the last n files of file """ # can't define as a default parameter because LOGFILE name is potentially # modified based on SIMULATION flag if not file: file = LOGFILE # special case: for the main logfile, we always keep a rolling log if not rolling and file == LOGFILE: rolling = ROLLING_LOGFILE #if file == LOGFILE: <----SKIPPED LINES----> return min(potential_distance1, potential_distance2) def SecondsToHhMm(seconds, colon=False): """Converts integer number of seconds to xhym string (i.e.: 7h17m) or to 7:17. Args: seconds: number of seconds colon: controls format; if False, format is 7h17m; if True, format is 7:17. Returns: String representation of hours and minutes. """ if seconds is None: return KEY_NOT_PRESENT_STRING[:3] minutes = int(abs(seconds) / SECONDS_IN_MINUTE) if minutes > MINUTES_IN_HOUR: hours = int(minutes / MINUTES_IN_HOUR) minutes = minutes % MINUTES_IN_HOUR if colon: text = str(hours) + ':' + str(minutes) else: text = str(hours) + 'h' + str(minutes) + 'm' else: if colon: text = ':' + str(minutes) else: text = str(minutes) + 'm' return text def SecondsToHours(seconds): """Converts integer number of seconds to xh string (i.e.: 7h). Args: seconds: number of seconds Returns: String representation of hours. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) hours = round(minutes / MINUTES_IN_HOUR) return hours def SecondsToDdHh(seconds): """Converts integer number of seconds to xdyh string (i.e.: 7d17h). <----SKIPPED LINES----> Returns: Boolean indicating whether the write was successful. """ try: with open(filename, 'w') as content_file: content_file.write(text) except IOError: if log_exception: Log('Unable to write to '+filename) return False return True def PrependFileName(full_path, prefix): """Converts /dir/file.png to /dir/prefixfile.png.""" directory, file_name = os.path.split(full_path) file_name = prefix+file_name return os.path.join(directory, file_name) def UnpickleObjectFromFile(full_path, date_segmentation, max_days=None): """Load a repository of pickled flight data into memory. Args: full_path: name (potentially including path) of the pickled file date_segmentation: If true, searches for all files that have a prefix of yyyy-mm-dd as a prefix to the file name specified in the full path, and loads them in sequence for unpickling; if false, uses the full_path as is and loads just that single file. max_days: Integer that, if specified, indicates maximum number of days of files to load back in; otherwise, loads all. Returns: Return a list of all the flights, in the same sequence as written to the file. """ if date_segmentation: directory, file = os.path.split(full_path) files = os.listdir(directory) if max_days: # no need to read any files older than x days earliest_date = EpochDisplayTime(time.time() - max_days*SECONDS_IN_DAY, '%Y-%m-%d') files = [f for f in files if f[:10] >= earliest_date] files = sorted([os.path.join(directory, f) for f in files if file in f]) else: if os.path.exists(full_path): files = [full_path] else: return [] data = [] for file in files: try: with open(file, 'rb') as f: while True: data.append(pickle.load(f)) except (EOFError, pickle.UnpicklingError): pass return data def PickleObjectToFile(data, full_path, date_segmentation): """Append one pickled flight to the end of binary file. Args: data: data to pickle full_path: name (potentially including path) of the pickled file date_segmentation: boolean indicating whether the date string yyyy-mm-dd should be prepended to the file name in full_path based on the current date, so that pickled files are segmented by date. <----SKIPPED LINES----> if date_segmentation: full_path = PrependFileName(full_path, EpochDisplayTime(time.time(), '%Y-%m-%d-')) try: with open(full_path, 'ab') as f: f.write(pickle.dumps(data)) except IOError: Log('Unable to append pickle ' + full_path) def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now): """Identifies newly seen aircraft and removes aircraft that haven't been seen recently. Updates persistent_nearby_aircraft as follows: flights that have been last seen more than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of flights that have been seen again. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. current_nearby_aircraft: dictionary where keys are flight numbers, and the values are themselves dictionaries with key-value pairs about that flight, with at least one of the kv-pairs being the time the flight was seen. now: the timestamp of the flights in the current_nearby_aircraft. Returns: A list of newly-nearby flight numbers. """ newly_nearby_flight_numbers = [] for flight_number in current_nearby_aircraft: if flight_number not in persistent_nearby_aircraft: newly_nearby_flight_numbers.append(flight_number) persistent_nearby_aircraft[flight_number] = now flights_to_delete = [] for flight_number in persistent_nearby_aircraft: if (flight_number not in current_nearby_aircraft and (now - persistent_nearby_aircraft[flight_number]) > PERSISTENCE_SECONDS): flights_to_delete.append(flight_number) for flight_number in flights_to_delete: del persistent_nearby_aircraft[flight_number] return newly_nearby_flight_numbers def ScanForNewFlights(persistent_nearby_aircraft, persistent_path, log_jsons): """Determines if there are any new aircraft in the radio message. The radio is continuously dumping new json messages to the Raspberry pi with all the flights currently observed. This function picks up the latest radio json, and for any new nearby flights - there should generally be at most one new flight on each pass through - gets additional flight data from FlightAware and augments the flight definition with the relevant fields to keep. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. log_jsons: boolean indicating whether we should pickle the JSONs. Returns: A tuple: - updated persistent_nearby_aircraft - (possibly empty) dictionary of flight attributes of the new flight upon its first observation. - the time of the radio observation if present; None if no radio dump - a dictionary of attributes about the dump itself (i.e.: # of flights; furthest observed flight, etc.) - persistent_path, a data structure containing past details of a flight's location as described in ParseDumpJson """ flight_details = {} now = time.time() if SIMULATION: (dump_json, json_time) = DUMP_JSONS[SIMULATION_COUNTER] else: dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True) json_desc_dict = {} current_nearby_aircraft = {} if dump_json: (current_nearby_aircraft, now, json_desc_dict, persistent_path) = ParseDumpJson(dump_json, persistent_path) if not SIMULATION and log_jsons: PickleObjectToFile((dump_json, now), PICKLE_DUMP_JSON_FILE, True) newly_nearby_flight_numbers = UpdateAircraftList( persistent_nearby_aircraft, current_nearby_aircraft, now) if newly_nearby_flight_numbers: if len(newly_nearby_flight_numbers) > 1: newly_nearby_flight_numbers_str = ', '.join(newly_nearby_flight_numbers) newly_nearby_flight_details_str = '\n'.join( [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_numbers]) Log('Multiple newly-nearby flights: %s\n%s' % ( newly_nearby_flight_numbers_str, newly_nearby_flight_details_str)) flight_number = newly_nearby_flight_numbers[0] flight_aware_json = {} if SIMULATION: json_times = [j[1] for j in FA_JSONS] if json_time in json_times: flight_aware_json = FA_JSONS[json_times.index(json_time)][0] elif flight_number: flight_aware_json = GetFlightAwareJson(flight_number) if flight_aware_json: UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, False) else: Log('No json returned from Flightaware for flight: %s' % flight_number) UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, True) flight_details = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) if not SIMULATION and log_jsons: PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE, True) # Augment FlightAware details with radio / radio-derived details flight_details.update(current_nearby_aircraft[flight_number]) # Augment with the past location data flight_details['persistent_path'] = persistent_path[flight_number][1] return ( persistent_nearby_aircraft, flight_details, now, json_desc_dict, persistent_path) def DescribeDumpJson(parsed): """Generates a dictionary with descriptive attributes about the dump json file. Args: parsed: The parsed json file. Returns: Dictionary with attributes about radio range, number of flights seen, etc. """ json_desc_dict = {} json_desc_dict['now'] = parsed['now'] aircraft = [a for a in parsed['aircraft'] if a['seen'] < PERSISTENCE_SECONDS] json_desc_dict['radio_range_flights'] = len(aircraft) aircraft_with_pos = [a for a in aircraft if 'lat' in a and 'lon' in a] current_distances = [HaversineDistanceMeters( HOME, (a['lat'], a['lon'])) for a in aircraft_with_pos] current_distances = [ d * FEET_IN_METER / FEET_IN_MILE for d in current_distances if d is not None] if current_distances: json_desc_dict['radio_range_miles'] = max(current_distances) return json_desc_dict def ParseDumpJson(dump_json, persistent_path): """Identifies all airplanes within given distance of home from the dump1090 file. Since the dump1090 json will have messages from all flights that the antenna has picked up, we want to keep only flights that are within a relevant distance to us, and also to extract from the full set of data in the json to just the relevant fields for additional analysis. Args: dump_json: The text representation of the json message from dump1090-mutability persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. Returns: Return tuple: - dictionary of all nearby planes, where keys are flight numbers (i.e.: 'SWA7543'), and the value is itself a dictionary of attributes. - time stamp in the json file. - dictionary of attributes about the radio range - persistent dictionary of the track of recent flights, where keys are the flight numbers and the value is a tuple, the first element being when the flight was last seen in this radio, and the second is a list of dictionaries with past location info from the radio where it's been seen, i.e.: d[flight] = (timestamp, [{}, {}, {}]) """ parsed = json.loads(dump_json) now = parsed['now'] nearby_aircraft = {} # Build dictionary summarizing characteristics of the dump_json itself json_desc_dict = DescribeDumpJson(parsed) for aircraft in parsed['aircraft']: simplified_aircraft = {} simplified_aircraft['now'] = now # flight_number flight_number = aircraft.get('flight') if flight_number: flight_number = flight_number.strip() if flight_number: simplified_aircraft['flight_number'] = flight_number if 'lat' in aircraft and 'lon' in aircraft: lat = aircraft['lat'] lon = aircraft['lon'] if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number): simplified_aircraft['lat'] = lat simplified_aircraft['lon'] = lon altitude = aircraft.get('altitude', aircraft.get('alt_baro')) if isinstance(altitude, numbers.Number): simplified_aircraft['altitude'] = altitude speed = aircraft.get('speed', aircraft.get('gs')) if speed is not None: simplified_aircraft['speed'] = speed vert_rate = aircraft.get('vert_rate', aircraft.get('baro_rate')) if vert_rate is not None: simplified_aircraft['vert_rate'] = vert_rate if aircraft.get('squawk') is not None: simplified_aircraft['squawk'] = aircraft.get('squawk') track = aircraft.get('track') if isinstance(track, numbers.Number): min_meters = MinMetersToHome((lat, lon), track) simplified_aircraft['track'] = track simplified_aircraft['min_feet'] = min_meters * FEET_IN_METER if HaversineDistanceMeters(HOME, (lat, lon)) < MIN_METERS: nearby_aircraft[flight_number] = simplified_aircraft # keep all that track info - once we start reporting on a nearby flight, it will # become part of the flight's persistent record. Also, note that as we are # building a list of tracks for each flight, and we are later assigning the # flight dictionary to point to the list, we just simply need to continue # updating this list to keep the dictionary up to date (i.e.: we don't need # to directly touch the flights dictionary in main). (last_seen, current_path) = persistent_path.get(flight_number, (None, [])) if ( # flight position has been updated with this radio signal not current_path or simplified_aircraft.get('lat') != current_path[-1].get('lat') or simplified_aircraft.get('lon') != current_path[-1].get('lon')): current_path.append(simplified_aircraft) persistent_path[flight_number] = (now, current_path) # if the flight was last seen too far in the past, remove the track info for flight_number in list(persistent_path.keys()): (last_seen, current_path) = persistent_path[flight_number] if last_seen < now - PERSISTENCE_SECONDS: persistent_path.pop(flight_number) return (nearby_aircraft, now, json_desc_dict, persistent_path) def GetFlightAwareJson(flight_number): """Scrapes the text json message from FlightAware for a given flight number. Given a flight number, loads the corresponding FlightAware webpage for that flight and extracts the relevant script that contains all the flight details from that page. Args: flight_number: text flight number (i.e.: SWA1234) Returns: Text representation of the json message from FlightAware. """ url = 'https://flightaware.com/live/flight/' + flight_number try: response = requests.get(url) except requests.exceptions.RequestException as e: <----SKIPPED LINES----> flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. percent_size_difference: the minimum size (i.e.: length) difference for the insight to warrant including the size details. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = DisplayFlightNumber(this_flight) last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number] # Last time this same flight flew a materially different type of aircraft if last_seen and 'flight_number' in this_flight: last_flight = last_seen[-1] last_aircraft = last_flight.get('aircraft_type_friendly') last_aircraft_length = aircraft_length.get(last_aircraft, 0) this_aircraft = this_flight.get('aircraft_type_friendly') this_aircraft_length = aircraft_length.get(this_aircraft, 0) this_likely_commercial_flight = ( this_flight.get('origin_iata') and this_flight.get('destination_iata')) if this_likely_commercial_flight and this_aircraft and not this_aircraft_length: Log('%s used in a flight with defined origin & destination but yet is ' 'missing length details' % this_aircraft, file=LOGFILE) likely_same_commercial_flight = ( last_flight.get('origin_iata') == this_flight.get('origin_iata') and last_flight.get('destination_iata') == this_flight.get('destination_iata') and last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign')) this_aircraft_bigger = False last_aircraft_bigger = False if (likely_same_commercial_flight and this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)): this_aircraft_bigger = True comparative_text = 'larger' elif (likely_same_commercial_flight and last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)): <----SKIPPED LINES----> min_group_qty=5, percentile_low=10, percentile_high=90) # we only want to do this if we're already at ~75% of the number of flights we'd # expect to see for the hour flight_hours = {} for flight in flights: if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY and DisplayTime( flight, '%-I%p') == DisplayTime(flights[-1], '%-I%p'): flight_hours[DisplayTime(flight, '%-d')] = flight_hours.get( DisplayTime(flight, '%-d'), 0) + 1 min_this_hour_flights = max(3, 0.75 * max(flight_hours.values())) # Once we've commented on the insights for an hour or day, we don't want to do it again hour_delay_frequency_flag = FLAG_INSIGHT_HOUR_DELAY_FREQUENCY hour_delay_time_flag = FLAG_INSIGHT_HOUR_DELAY_TIME date_delay_frequency_flag = FLAG_INSIGHT_DATE_DELAY_FREQUENCY date_delay_time_flag = FLAG_INSIGHT_DATE_DELAY_TIME for flight in flights[:-1]: insights = flight['insight_types'] this_hour = DisplayTime(flights[-1], '%x %-I%p') this_day = DisplayTime(flights[-1], '%x') if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_FREQUENCY in insights): hour_delay_frequency_flag = None if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_TIME in insights): hour_delay_time_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_FREQUENCY in insights): date_delay_frequency_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_TIME in insights): date_delay_time_flag = None def TodaysHour(f): f_date = DisplayTime(f, '%x') f_hour = DisplayTime(f, '%-I%p') if f_date == DisplayTime(flights[-1], '%x'): return '%s flights today' % f_hour <----SKIPPED LINES----> for flight in flights: flight[function[0]] = function[1](flight) # these functions return dictionary of values functions = [ lambda f: FlightAnglesSecondsElapsed(f, 0, '_00s'), lambda f: FlightAnglesSecondsElapsed(f, 10, '_10s'), lambda f: FlightAnglesSecondsElapsed(f, 20, '_20s'), DisplayDepartureTimes] for function in functions: for flight in flights: flight.update(function(flight)) all_keys = set() for f in flights: all_keys.update(f.keys()) all_keys = list(all_keys) all_keys.sort() keys_logical_order = [ 'now_date', 'now_time', 'now_datetime', 'now', 'flight_number', 'origin_iata', 'destination_iata', 'altitude', 'min_feet', 'vert_rate', 'speed', 'distance', 'delay_seconds', 'airline_call_sign', 'aircraft_type_friendly', 'azimuth_degrees_00s', 'azimuth_degrees_10s', 'azimuth_degrees_20s', 'altitude_degrees_00s', 'altitude_degrees_10s', 'altitude_degrees_20s', 'ground_distance_feet_00s', 'ground_distance_feet_10s', 'ground_distance_feet_20s', 'crow_distance_feet_00s', 'crow_distance_feet_10s', 'crow_distance_feet_20s'] for key in all_keys: if key not in keys_logical_order: keys_logical_order.append(key) f = open(filename, 'w') f.write(','.join(keys_logical_order)+'\n') for flight in flights: f.write(','.join(['"'+str(flight.get(k))+'"' for k in keys_logical_order])+'\n') f.close() def SimulationSetup(): """Updates global variable file names and loads in JSON data for simulation runs.""" # Clear file so that shell tail -f process can continue to point to same file def ClearFile(filename): if os.path.exists(filename): with open(filename, 'w') as f: f.write('') global SIMULATION SIMULATION = True global DUMP_JSONS DUMP_JSONS = UnpickleObjectFromFile(PICKLE_DUMP_JSON_FILE, True) global FA_JSONS FA_JSONS = UnpickleObjectFromFile(PICKLE_FA_JSON_FILE, True) <----SKIPPED LINES----> def PerformGracefulShutdown(queues, shutdown, reboot): """Complete the graceful shutdown process by cleaning up. Args: queues: iterable of queues shared with child processes to be closed shutdown: tuple of shared flags with child processes to initiate shutdown in children reboot: boolean indicating whether we should trigger a reboot """ reboot_msg = '' if reboot: reboot_msg = ' and rebooting' Log('Shutting down self (%d)%s' % (os.getpid(), reboot_msg)) for q in queues: q.close() for v in shutdown: # send the shutdown signal to child processes v.value = 1 if RASPBERRY_PI: RPi.GPIO.cleanup() if reboot or REBOOT_SIGNAL: time.sleep(10) # wait 10 seconds for children to shut down as well os.system('sudo reboot') sys.exit() def FindRunningParents(): """Returns list of proc ids of processes with identically-named python file running. In case there are multiple children processes spawned with the same name, such as via multiprocessing, this will only return the parent id (since a killed child process will likely just be respawned). """ this_process_id = os.getpid() this_process_name = os.path.basename(sys.argv[0]) pids = [] pid_pairs = [] for proc in psutil.process_iter(): try: <----SKIPPED LINES----> args=(to_servo_q, to_main_q, shutdown[1])) return remote, servo def ValidateSingleRunning(enabled, start_function, p=None, args=()): """Restarts a new instance of multiprocessing process if not running""" if not SHUTDOWN_SIGNAL: if not enabled: if p is not None: # must have just requested a disabling of single instance args[2].value = 1 # trigger a shutdown on the single instance return None if p is None or not p.is_alive(): if p is None: Log('Process for %s starting for first time' % str(start_function)) else: Log('Process (%s) for %s died; restarting' % (str(p), str(start_function))) args[2].value = 0 # (re)set shutdown flag to allow function to run p = multiprocessing.Process(target=start_function, args=args) p.daemon = True p.start() return p def EnqueueArduinos(flights, json_desc_dict, configuration, to_servo_q, to_remote_q): """Send latest data to arduinos via their shared-memory queues""" last_flight = {} if flights: last_flight = dict(flights[-1]) if SIMULATION: now = json_desc_dict['now'] else: now = time.time() additional_attributes = {} today = EpochDisplayTime(now, '%x') flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today]) additional_attributes['flight_count_today'] = flight_count_today additional_attributes['simulation'] = SIMULATION message = (last_flight, json_desc_dict, configuration, additional_attributes) try: if 'enable_servos' in configuration: to_servo_q.put(message, block=False) if 'enable_remote' in configuration: <----SKIPPED LINES----> if mtime == os.path.getmtime(f): shutil.move(tmp_f, f) else: print('Aborted: failed to bootstrap %s: file changed while in process' % full_path) return def ResetLogs(config): """Clears the non-scrolling logs if reset_logs in config.""" if 'reset_logs' in config: Log('Reset logs') for f in (STDERR_FILE, BACKUP_FILE, SERVICE_VERIFICATION_FILE): if RemoveFile(f): open(f, 'a').close() config.pop('reset_logs') config = BuildSettings(config) WriteFile(CONFIG_FILE, config) return config def CheckTemperature(fan_power): """Turn on fan if temperature exceeds threshold.""" if RASPBERRY_PI: temperature = gpiozero.CPUTemperature().temperature if temperature > TEMP_FAN_TURN_ON_CELSIUS and not fan_power: fan_power = True RPi.GPIO.output(GPIO_FAN, True) Log('Fan turned on at temperature %.1f' % temperature) elif temperature < TEMP_FAN_TURN_OFF_CELSIUS and fan_power: fan_power = False RPi.GPIO.output(GPIO_FAN, False) Log('Fan turned off at temperature %.1f' % temperature) return fan_power pin_values = {} # caches last set value def SetPinMode(): """Initialize output GPIO pins for output on Raspberry Pi.""" global pin_values if RASPBERRY_PI: RPi.GPIO.setmode(RPi.GPIO.BCM) for pin in ( GPIO_ERROR_VESTABOARD_CONNECTION, GPIO_ERROR_FLIGHT_AWARE_CONNECTION, GPIO_ERROR_ARDUINO_SERVO_CONNECTION, GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, GPIO_ERROR_BATTERY_CHARGE, GPIO_FAN, GPIO_UNUSED_1, GPIO_UNUSED_2): # Initialize some pins to start in error condition if pin in ( GPIO_ERROR_ARDUINO_SERVO_CONNECTION, GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, GPIO_UNUSED_1, GPIO_UNUSED_2): pin_values[pin[0]] = True else: pin_values[pin[0]] = False if RASPBERRY_PI: RPi.GPIO.setup(pin[0], RPi.GPIO.OUT) RPi.GPIO.output(pin[0], pin_values[pin[0]]) if RASPBERRY_PI: # configure soft reset button RPi.GPIO.setup(GPIO_SOFT_RESET[0], RPi.GPIO.IN, pull_up_down=RPi.GPIO.PUD_DOWN) RPi.GPIO.setup(GPIO_SOFT_RESET[1], RPi.GPIO.OUT) RPi.GPIO.output(GPIO_SOFT_RESET[1], True) RPi.GPIO.add_event_detect(GPIO_SOFT_RESET[0], RPi.GPIO.RISING) RPi.GPIO.add_event_callback(GPIO_SOFT_RESET[0], InterruptRebootFromButton) def UpdateStatusLight(pin, value): """Sets the Raspberry Pi GPIO pin high (True) or low (False) based on value.""" global pin_values if value: msg = pin[1] else: msg = pin[2] if RASPBERRY_PI: RPi.GPIO.output(pin[0], value) if value: pin_setting = 'HIGH' relay_light_value = 'OFF' else: pin_setting = 'LOW' relay_light_value = 'ON' msg += '; RPi GPIO pin %d set to %s; relay light #%d should now be %s' % ( pin[0], pin_setting, pin[3], relay_light_value) if pin_values[pin[0]] != value: Log(msg) pin_values[pin[0]] = value def RemoveFile(file): """Removes a file if it exists, returning a boolean indicating if it had existed.""" if os.path.exists(file): os.remove(file) return True return False def main(): """Traffic cop between incoming radio flight messages, configuration, and messageboard. This is the main logic, checking for new flights, augmenting the radio signal with additional web-scraped data, and generating messages in a form presentable to the messageboard. """ RemoveFile(LOGFILE_LOCK) # Since this clears log files, it should occur first before we start logging if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() # This flag slows down simulation time around a flight, great for debugging the arduinos simulation_slowdown = bool('-f' in sys.argv) # Redirect any errors to a log file instead of the screen, and add a datestamp if not SIMULATION: sys.stderr = open(STDERR_FILE, 'a') Log('', STDERR_FILE) Log('Starting up process %d' % os.getpid()) already_running_ids = FindRunningParents() if already_running_ids: for pid in already_running_ids: Log('Sending termination signal to %d' % pid) os.kill(pid, signal.SIGTERM) SetPinMode() fan_power = False configuration = ReadAndParseSettings(CONFIG_FILE) startup_time = time.time() json_desc_dict = {} flights = UnpickleObjectFromFile(PICKLE_FLIGHTS, True, max_days=MAX_INSIGHT_HORIZON_DAYS) # Clear the loaded flight of any cached data, identified by keys with a specific # suffix, since code fixes may change the values for some of those cached elements for flight in flights: for key in list(flight.keys()): if key.endswith(CACHED_ELEMENT_PREFIX): flight.pop(key) # If we're displaying just a single insight message, we want it to be something # unique, to the extent possible; this dict holds a count of the diff types of messages # displayed so far insight_message_distribution = {} # bootstrap the flight insights distribution from a list of insights on each # flight (i.e.: flight['insight_types'] for a given flight might look like # [1, 2, 7, 9], or [], to indicate which insights were identified; this then # transforms that into {0: 25, 1: 18, ...} summing across all flights. for flight in flights: distribution = flight['insight_types'] for key in distribution: insight_message_distribution[key] = ( insight_message_distribution.get(key, 0) + 1) remote, servo, to_remote_q, to_servo_q, to_main_q, shutdown = InitArduinos(configuration) # used in simulation to print the hour of simulation once per simulated hour prev_simulated_hour = '' persistent_nearby_aircraft = {} # key = flight number; value = last seen epoch persistent_path = {} histogram = {} # Next up to print is index 0; this is a list of tuples: # tuple element#1: flag indicating the type of message that this is # tuple element#2: the message itself message_queue = [] next_message_time = time.time() # We repeat the loop every x seconds; this ensures that if the processing time is long, # we don't wait another x seconds after processing completes next_loop_time = time.time() + LOOP_DELAY_SECONDS # These files are read only if the version on disk has been modified more recently # than the last time it was read last_dump_json_timestamp = 0 WaitUntilKillComplete(already_running_ids) Log('Finishing initialization of %d; starting radio polling loop' % os.getpid()) while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS): new_configuration = ReadAndParseSettings(CONFIG_FILE) CheckForNewFilterCriteria(configuration, new_configuration, message_queue, flights) configuration = new_configuration ResetLogs(configuration) # clear the logs if requested # if this is a SIMULATION, then process every diff dump. But if it isn't a simulation, # then only read & do related processing for the next dump if the last-modified # timestamp indicates the file has been updated since it was last read. tmp_timestamp = 0 if not SIMULATION: dump_json_exists = os.path.exists(DUMP_JSON_FILE) if dump_json_exists: tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE) if (SIMULATION and DumpJsonChanges()) or ( not SIMULATION and dump_json_exists and tmp_timestamp > last_dump_json_timestamp): last_dump_json_timestamp = tmp_timestamp (persistent_nearby_aircraft, flight, now, json_desc_dict, persistent_path) = ScanForNewFlights( persistent_nearby_aircraft, persistent_path, configuration.get('log_jsons', False)) if flight: flights.append(flight) remote, servo = RefreshArduinos( remote, servo, to_remote_q, to_servo_q, to_main_q, shutdown, flights, json_desc_dict, configuration) flight_meets_display_criteria = FlightMeetsDisplayCriteria( flight, configuration, log=True) if flight_meets_display_criteria: flight_message = (FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight)) # display the next message about this flight now! next_message_time = time.time() message_queue.insert(0, flight_message) # and delete any queued insight messages about other flights that have # not yet displayed, since a newer flight has taken precedence messages_to_delete = [m for m in message_queue if m[0] == FLAG_MSG_INTERESTING] if messages_to_delete and VERBOSE: Log( 'Deleting messages from queue due to new-found plane: %s' % <----SKIPPED LINES----> if simulated_hour != prev_simulated_hour: print(simulated_hour) prev_simulated_hour = simulated_hour histogram = ReadAndParseSettings(HISTOGRAM_CONFIG_FILE) RemoveFile(HISTOGRAM_CONFIG_FILE) # We also need to make sure there are flights on which to generate a histogram! Why # might there not be any flights? Primarily during a simulation, if there's a # lingering histogram file at the time of history restart. if histogram and not flights: Log('Histogram requested (%s) but no flights in memory' % histogram) if histogram and flights: message_queue.extend(TriggerHistograms(flights, histogram)) # check time & if appropriate, display next message from queue next_message_time = ManageMessageQueue(message_queue, next_message_time, configuration) reboot = CheckRebootNeeded(startup_time, message_queue, json_desc_dict, configuration) fan_power = CheckTemperature(fan_power) if not SIMULATION: time.sleep(max(0, next_loop_time - time.time())) next_loop_time = time.time() + LOOP_DELAY_SECONDS else: SIMULATION_COUNTER += 1 if simulation_slowdown: SimulationSlowdownNearFlight(flights, persistent_nearby_aircraft) if SHUTDOWN_SIGNAL: # do a graceful exit PerformGracefulShutdown((to_remote_q, to_servo_q, to_main_q), shutdown, reboot) if SIMULATION: SimulationEnd(message_queue, flights) PerformGracefulShutdown((to_remote_q, to_servo_q, to_main_q), shutdown, reboot) if __name__ == "__main__": #interrupt, as in ctrl-c signal.signal(signal.SIGINT, InterruptShutdownFromSignal) #terminate, when another instance found or via kill signal.signal(signal.SIGTERM, InterruptShutdownFromSignal) if '-i' in sys.argv: BootstrapInsightList() else: main() |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
123456789101112131415161718192021222324252627282930313233 343536 373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117 138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275 278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 322323324325326327328329330331332333334335336337338339340341 342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863 104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125 1127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214 1215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449 1450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507 22072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250 34173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457 4607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670 482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861 496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014 52205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245 52465247 52485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468 546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585 562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672 | #!/usr/bin/python3 import datetime import io import json import math import multiprocessing import numbers import os import pickle import queue import re import shutil import signal import statistics import sys import textwrap import time import bs4 import dateutil.relativedelta import filelock import numpy import matplotlib import matplotlib.pyplot import psutil import pycurl import pytz import requests import tzlocal import unidecode from constants import RASPBERRY_PI, MESSAGEBOARD_PATH, WEBSERVER_PATH import arduino if RASPBERRY_PI: import gpiozero # pylint: disable=E0401 import RPi.GPIO # pylint: disable=E0401 VERBOSE = False # additional messages logged SHUTDOWN_SIGNAL = False REBOOT_SIGNAL = False SIMULATION = False SIMULATION_COUNTER = 0 SIMULATION_PREFIX = 'SIM_' PICKLE_DUMP_JSON_FILE = 'pickle/dump_json.pk' PICKLE_FA_JSON_FILE = 'pickle/fa_json.pk' DUMP_JSONS = None # loaded only if in simulation mode FA_JSONS = None # loaded only if in simulation mode HOME_LAT = 37.64406 HOME_LON = -122.43463 HOME = (HOME_LAT, HOME_LON) # lat / lon tuple of antenna HOME_ALT = 29 #altitude in meters RADIUS = 6371.0e3 # radius of earth in meters FEET_IN_METER = 3.28084 FEET_IN_MILE = 5280 METERS_PER_SECOND_IN_KNOTS = 0.514444 MIN_METERS = 5000/FEET_IN_METER # only planes within this distance will be detailed # planes not seen within MIN_METERS in PERSISTENCE_SECONDS seconds will be dropped from # the nearby list PERSISTENCE_SECONDS = 300 TRUNCATE = 50 # max number of keys to include in a histogram image file # number of seconds to pause between each radio poll / command processing loop LOOP_DELAY_SECONDS = 1 # number of seconds to wait between recording heartbeats to the status file HEARTBEAT_SECONDS = 10 # version control directory CODE_REPOSITORY = '' VERSION_REPOSITORY = 'versions/' VERSION_WEBSITE_PATH = VERSION_REPOSITORY VERSION_MESSAGEBOARD = None VERSION_ARDUINO = None MAX_INSIGHT_HORIZON_DAYS = 30 # This file is where the radio drops its json file DUMP_JSON_FILE = '/run/readsb/aircraft.json' # At the time a flight is first identified as being of interest (in that it falls # within MIN_METERS meters of HOME), it - and core attributes derived from FlightAware, # if any - is appended to the end of this pickle file. However, since this file is # cached in working memory, flights older than 30 days are flushed from this periodically. PICKLE_FLIGHTS = 'pickle/flights.pk' # Status data about messageboard - is it running, etc. Specifically, has tuples # of data (timestamp, system_id, status), where system_id is either the pin id of GPIO, # or a 0 to indicate overall system, and status is boolean PICKLE_DASHBOARD = 'pickle/dashboard.pk' CACHED_ELEMENT_PREFIX = 'cached_' # This web-exposed file is used for non-error messages that might highlight data or # code logic to check into. It is only cleared out manually. LOGFILE = 'log.txt' LOGFILE_LOCK = 'log.txt.lock' # Identical to the LOGFILE, except it includes just the most recent n lines. Newest # lines are at the end. ROLLING_LOGFILE = 'rolling_log.txt' #file for error messages # Users can trigger .png histograms analogous to the text ones from the web interface; # this is the folder (within WEBSERVER_PATH) where those files are placed WEBSERVER_IMAGE_FOLDER = 'images/' # Multiple histograms can be generated, i.e. for airline, aircraft, day of week, etc. # The output files are named by the prefix & suffix, i.e.: prefix + type + . + suffix, # as in histogram_aircraft.png. These names match up to the names expected by the html # page that displays the images. Also, note that the suffix is interpreted by matplotlib # to identify the image format to create. HISTOGRAM_IMAGE_PREFIX = 'histogram_' <----SKIPPED LINES----> # away we anticipate the flight being at its closest point to HOME. As those two # parameters are manipulated in the settings, a histogram is displayed with one or # potentially two series, showing the present and potentially prior-set distribution # of flights, by hour throughout the day, over the last seven days, normalized to # flights per day. This allows those parameters to be fine-tuned in a useful way. # This file is the location, on the webserver, of that image, which needs to be in # alignment with the html page that displays it. HOURLY_IMAGE_FILE = 'hours.png' # This is all messages that have been sent to the board since the last time the # file was manually cleared. Newest messages are at the bottom. It is visible at the # webserver. ALL_MESSAGE_FILE = 'all_messages.txt' #enumeration of all messages sent to board # This shows the most recent n messages sent to the board. Newest messages are at the # top for easier viewing of "what did I miss". ROLLING_MESSAGE_FILE = 'rolling_messages.txt' STDERR_FILE = 'stderr.txt' BACKUP_FILE = 'backup.txt' SERVICE_VERIFICATION_FILE = 'service-verification.txt' UPTIMES_FILE = 'uptimes.html' FLAG_MSG_FLIGHT = 1 # basic flight details FLAG_MSG_INTERESTING = 2 # random tidbit about a flight FLAG_MSG_HISTOGRAM = 3 # histogram message FLAG_INSIGHT_LAST_SEEN = 0 FLAG_INSIGHT_DIFF_AIRCRAFT = 1 FLAG_INSIGHT_NTH_FLIGHT = 2 FLAG_INSIGHT_GROUNDSPEED = 3 FLAG_INSIGHT_ALTITUDE = 4 FLAG_INSIGHT_VERTRATE = 5 FLAG_INSIGHT_FIRST_DEST = 6 FLAG_INSIGHT_FIRST_ORIGIN = 7 FLAG_INSIGHT_FIRST_AIRLINE = 8 FLAG_INSIGHT_FIRST_AIRCRAFT = 9 FLAG_INSIGHT_LONGEST_DELAY = 10 FLAG_INSIGHT_FLIGHT_DELAY_FREQUENCY = 11 FLAG_INSIGHT_FLIGHT_DELAY_TIME = 12 FLAG_INSIGHT_AIRLINE_DELAY_FREQUENCY = 13 FLAG_INSIGHT_AIRLINE_DELAY_TIME = 14 FLAG_INSIGHT_DESTINATION_DELAY_FREQUENCY = 15 FLAG_INSIGHT_DESTINATION_DELAY_TIME = 16 FLAG_INSIGHT_HOUR_DELAY_FREQUENCY = 17 FLAG_INSIGHT_HOUR_DELAY_TIME = 18 FLAG_INSIGHT_DATE_DELAY_FREQUENCY = 19 FLAG_INSIGHT_DATE_DELAY_TIME = 20 INSIGHT_TYPES = 21 TEMP_FAN_TURN_ON_CELSIUS = 65 TEMP_FAN_TURN_OFF_CELSIUS = 55 # GPIO relay connections # format: (GPIO pin, true message, false message, relay number, description) GPIO_ERROR_VESTABOARD_CONNECTION = ( 22, 'ERROR: Vestaboard unavailable', 'SUCCESS: Vestaboard available', 1, 'Vestaboard connected') GPIO_ERROR_FLIGHT_AWARE_CONNECTION = ( 23, 'ERROR: FlightAware not available', 'SUCCESS: FlightAware available', 2, 'FlightAware connected') GPIO_ERROR_ARDUINO_SERVO_CONNECTION = ( 24, 'ERROR: Servos not running or lost connection', 'SUCCESS: Handshake with servo Arduino received', 3, 'Hemisphere connected') GPIO_ERROR_ARDUINO_REMOTE_CONNECTION = ( 25, 'ERROR: Remote not running or lost connection', 'SUCCESS: Handshake with remote Arduino received', 4, 'Remote connected') GPIO_ERROR_BATTERY_CHARGE = ( 26, 'ERROR: Remote battery is low', 'SUCCESS: Remote battery recharged', 5, 'Remote charged') GPIO_FAN = ( 5, 'ERROR: RPi above %dC degrees' % TEMP_FAN_TURN_ON_CELSIUS, 'SUCCESS: RPi below %dC degrees' % TEMP_FAN_TURN_OFF_CELSIUS, 7, 'Thermal condition') # for future expansion GPIO_UNUSED_1 = ( 27, 'Undefined condition set to true', 'Undefined condition set to false', 6) GPIO_UNUSED_2 = ( 6, 'Undefined condition set to true', 'Undefined condition set to false', 8) # GPIO pushbutton connections - (GPIO pin switch in; GPIO pin LED out) GPIO_SOFT_RESET = (20, 21) #if running on raspberry, then need to prepend path to file names if RASPBERRY_PI: PICKLE_FLIGHTS = MESSAGEBOARD_PATH + PICKLE_FLIGHTS PICKLE_DASHBOARD = MESSAGEBOARD_PATH + PICKLE_DASHBOARD LOGFILE = MESSAGEBOARD_PATH + LOGFILE PICKLE_DUMP_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_DUMP_JSON_FILE PICKLE_FA_JSON_FILE = MESSAGEBOARD_PATH + PICKLE_FA_JSON_FILE CODE_REPOSITORY = MESSAGEBOARD_PATH HISTOGRAM_CONFIG_FILE = WEBSERVER_PATH + HISTOGRAM_CONFIG_FILE CONFIG_FILE = WEBSERVER_PATH + CONFIG_FILE ROLLING_MESSAGE_FILE = WEBSERVER_PATH + ROLLING_MESSAGE_FILE ALL_MESSAGE_FILE = WEBSERVER_PATH + ALL_MESSAGE_FILE ROLLING_LOGFILE = WEBSERVER_PATH + ROLLING_LOGFILE STDERR_FILE = WEBSERVER_PATH + STDERR_FILE BACKUP_FILE = WEBSERVER_PATH + BACKUP_FILE SERVICE_VERIFICATION_FILE = WEBSERVER_PATH + SERVICE_VERIFICATION_FILE UPTIMES_FILE = WEBSERVER_PATH + UPTIMES_FILE HISTOGRAM_IMAGE_PREFIX = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_IMAGE_PREFIX HISTOGRAM_EMPTY_IMAGE_FILE = ( WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HISTOGRAM_EMPTY_IMAGE_FILE) HOURLY_IMAGE_FILE = WEBSERVER_PATH + WEBSERVER_IMAGE_FOLDER + HOURLY_IMAGE_FILE VERSION_REPOSITORY = WEBSERVER_PATH + VERSION_REPOSITORY TIMEZONE = 'US/Pacific' # timezone of display TZ = pytz.timezone(TIMEZONE) KNOWN_AIRPORTS = ('SJC', 'SFO', 'OAK') # iata codes that we don't need to expand SPLITFLAP_CHARS_PER_LINE = 22 SPLITFLAP_LINE_COUNT = 6 DIRECTIONS_4 = ['N', 'E', 'S', 'W'] DIRECTIONS_8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] DIRECTIONS_16 = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] HOURS = ['12a', ' 1a', ' 2a', ' 3a', ' 4a', ' 5a', ' 6a', ' 7a', ' 8a', ' 9a', '10a', '11a', '12p', ' 1p', ' 2p', ' 3p', ' 4p', ' 5p', ' 6p', ' 7p', ' 8p', ' 9p', '10p', '11p'] SECONDS_IN_MINUTE = 60 MINUTES_IN_HOUR = 60 <----SKIPPED LINES----> MINUTES_IN_DAY = MINUTES_IN_HOUR * HOURS_IN_DAY SECONDS_IN_DAY = SECONDS_IN_HOUR * HOURS_IN_DAY # Units confirmed here: # www.adsbexchange.com/forum/threads/units-in-the-dump1090-json-file.630617/#post-639541 CLIMB_RATE_UNITS = 'fpm' #speed units from tracker are knots, based on dump-1090/track.c #https://github.com/SDRplay/dump1090/blob/master/track.c SPEED_UNITS = 'kn' DISTANCE_UNITS = 'ft' # altitude # For displaying histograms # If a key is not present, how should it be displayed in histograms? KEY_NOT_PRESENT_STRING = 'Unknown' OTHER_STRING = 'Other' # What key strings should be listed last in sequence? # What key strings should be listed last in sequence? SORT_AT_END_STRINGS = [OTHER_STRING, KEY_NOT_PRESENT_STRING] # What is the sorted sequence of keys for days of week? DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] AIRCRAFT_LENGTH = {} # in meters AIRCRAFT_LENGTH['Airbus A220-100 (twin-jet)'] = 35 AIRCRAFT_LENGTH['Airbus A300F4-600 (twin-jet)'] = 54.08 AIRCRAFT_LENGTH['Airbus A319 (twin-jet)'] = 33.84 AIRCRAFT_LENGTH['Airbus A320 (twin-jet)'] = 37.57 AIRCRAFT_LENGTH['Airbus A320neo (twin-jet)'] = 37.57 AIRCRAFT_LENGTH['Airbus A321 (twin-jet)'] = 44.51 AIRCRAFT_LENGTH['Airbus A321neo (twin-jet)'] = 44.51 AIRCRAFT_LENGTH['Airbus A330-200 (twin-jet)'] = 58.82 AIRCRAFT_LENGTH['Airbus A330-300 (twin-jet)'] = 63.67 AIRCRAFT_LENGTH['Airbus A340-300 (quad-jet)'] = 63.69 AIRCRAFT_LENGTH['Airbus A350-1000 (twin-jet)'] = 73.79 AIRCRAFT_LENGTH['Airbus A350-900 (twin-jet)'] = 66.8 AIRCRAFT_LENGTH['Airbus A380-800 (quad-jet)'] = 72.72 AIRCRAFT_LENGTH['Boeing 737-400 (twin-jet)'] = 36.4 AIRCRAFT_LENGTH['Boeing 737-700 (twin-jet)'] = 33.63 AIRCRAFT_LENGTH['Boeing 737-800 (twin-jet)'] = 39.47 AIRCRAFT_LENGTH['Boeing 737-900 (twin-jet)'] = 42.11 AIRCRAFT_LENGTH['Boeing 747-400 (quad-jet)'] = 36.4 AIRCRAFT_LENGTH['Boeing 747-8 (quad-jet)'] = 76.25 AIRCRAFT_LENGTH['Boeing 757-200 (twin-jet)'] = 47.3 AIRCRAFT_LENGTH['Boeing 757-300 (twin-jet)'] = 54.4 AIRCRAFT_LENGTH['Boeing 767-200 (twin-jet)'] = 48.51 AIRCRAFT_LENGTH['Boeing 767-300 (twin-jet)'] = 54.94 AIRCRAFT_LENGTH['Boeing 777 (twin-jet)'] = (63.73 + 73.86) / 2 AIRCRAFT_LENGTH['Boeing 777-200 (twin-jet)'] = 63.73 AIRCRAFT_LENGTH['Boeing 777-200LR/F (twin-jet)'] = 63.73 AIRCRAFT_LENGTH['Boeing 777-300ER (twin-jet)'] = 73.86 AIRCRAFT_LENGTH['Boeing 787-10 (twin-jet)'] = 68.28 AIRCRAFT_LENGTH['Boeing 787-8 (twin-jet)'] = 56.72 AIRCRAFT_LENGTH['Boeing 787-9 (twin-jet)'] = 62.81 AIRCRAFT_LENGTH['Canadair Regional Jet CRJ-200 (twin-jet)'] = 26.77 AIRCRAFT_LENGTH['Canadair Regional Jet CRJ-700 (twin-jet)'] = 32.3 AIRCRAFT_LENGTH['Canadair Regional Jet CRJ-900 (twin-jet)'] = 36.2 AIRCRAFT_LENGTH['Canadair Challenger 350 (twin-jet)'] = 20.9 AIRCRAFT_LENGTH['Bombardier Challenger 300 (twin-jet)'] = 20.92 AIRCRAFT_LENGTH['Embraer 170/175 (twin-jet)'] = (29.90 + 31.68) / 2 AIRCRAFT_LENGTH['Embraer Phenom 300 (twin-jet)'] = 15.9 AIRCRAFT_LENGTH['EMBRAER 175 (long wing) (twin-jet)'] = 31.68 AIRCRAFT_LENGTH['Embraer ERJ-135 (twin-jet)'] = 26.33 AIRCRAFT_LENGTH['Cessna Caravan (single-turboprop)'] = 11.46 AIRCRAFT_LENGTH['Cessna Citation CJ2+ (twin-jet)'] = 14.53 AIRCRAFT_LENGTH['Cessna Citation II (twin-jet)'] = 14.54 AIRCRAFT_LENGTH['Cessna Citation Latitude (twin-jet)'] = 18.97 AIRCRAFT_LENGTH['Cessna Citation Sovereign (twin-jet)'] = 19.35 AIRCRAFT_LENGTH['Cessna Citation V (twin-jet)'] = 14.91 AIRCRAFT_LENGTH['Cessna Citation X (twin-jet)'] = 22.04 AIRCRAFT_LENGTH['Cessna Skyhawk (piston-single)'] = 8.28 AIRCRAFT_LENGTH['Cessna Skylane (piston-single)'] = 8.84 AIRCRAFT_LENGTH['Cessna T206 Turbo Stationair (piston-single)'] = 8.61 AIRCRAFT_LENGTH['Beechcraft Bonanza (33) (piston-single)'] = 7.65 AIRCRAFT_LENGTH['Beechcraft Super King Air 200 (twin-turboprop)'] = 13.31 AIRCRAFT_LENGTH['Beechcraft Super King Air 350 (twin-turboprop)'] = 14.22 AIRCRAFT_LENGTH['Beechcraft King Air 90 (twin-turboprop)'] = 10.82 AIRCRAFT_LENGTH['Learjet 45 (twin-jet)'] = 17.68 AIRCRAFT_LENGTH['Pilatus PC-12 (single-turboprop)'] = 14.4 def Log(message, file=None, rolling=None): """Write a message to a logfile along with a timestamp. Args: message: string message to write file: string representing file name and, if needed, path to the file to write to rolling: name of file that will keep only the last n files of file """ # can't define as a default parameter because LOGFILE name is potentially # modified based on SIMULATION flag if not file: file = LOGFILE # special case: for the main logfile, we always keep a rolling log if not rolling and file == LOGFILE: rolling = ROLLING_LOGFILE #if file == LOGFILE: <----SKIPPED LINES----> return min(potential_distance1, potential_distance2) def SecondsToHhMm(seconds, colon=False): """Converts integer number of seconds to xhym string (i.e.: 7h17m) or to 7:17. Args: seconds: number of seconds colon: controls format; if False, format is 7h17m; if True, format is 7:17. Returns: String representation of hours and minutes. """ if seconds is None: return KEY_NOT_PRESENT_STRING[:3] minutes = int(abs(seconds) / SECONDS_IN_MINUTE) if minutes > MINUTES_IN_HOUR: hours = int(minutes / MINUTES_IN_HOUR) minutes = minutes % MINUTES_IN_HOUR if colon: text = '%d:%02d' % (hours, minutes) else: text = '%dh%d' % (hours, minutes) else: if colon: text = ':%02d' % minutes else: text = '%dm' % minutes return text def SecondsToHours(seconds): """Converts integer number of seconds to xh string (i.e.: 7h). Args: seconds: number of seconds Returns: String representation of hours. """ minutes = int(abs(seconds) / SECONDS_IN_MINUTE) hours = round(minutes / MINUTES_IN_HOUR) return hours def SecondsToDdHh(seconds): """Converts integer number of seconds to xdyh string (i.e.: 7d17h). <----SKIPPED LINES----> Returns: Boolean indicating whether the write was successful. """ try: with open(filename, 'w') as content_file: content_file.write(text) except IOError: if log_exception: Log('Unable to write to '+filename) return False return True def PrependFileName(full_path, prefix): """Converts /dir/file.png to /dir/prefixfile.png.""" directory, file_name = os.path.split(full_path) file_name = prefix+file_name return os.path.join(directory, file_name) def UnpickleObjectFromFile(full_path, date_segmentation, max_days=None, filenames=False): """Load a repository of pickled data into memory. Args: full_path: name (potentially including path) of the pickled file date_segmentation: If true, searches for all files that have a prefix of yyyy-mm-dd as a prefix to the file name specified in the full path, and loads them in sequence for unpickling; if false, uses the full_path as is and loads just that single file. max_days: Integer that, if specified, indicates maximum number of days of files to load back in; otherwise, loads all. filenames: If true, rather than returning the list of data, returns a list of the filenames that would have been read. Returns: Return a list - either of the data, or of all the file names that would have been read. """ if date_segmentation: directory, file = os.path.split(full_path) d = '[0-9]' sep = '-' date_format = d*4 + sep + d*2 + sep + d*2 # yyyy-mm-dd exp = date_format + sep + file pattern = re.compile(exp) files = os.listdir(directory) if max_days: # no need to read any files older than x days earliest_date = EpochDisplayTime(time.time() - max_days*SECONDS_IN_DAY, '%Y-%m-%d') files = [f for f in files if f[:10] >= earliest_date] files = sorted([os.path.join(directory, f) for f in files if pattern.match(f)]) else: if os.path.exists(full_path): files = [full_path] else: return [] data = [] if filenames: return files for file in files: try: with open(file, 'rb') as f: while True: data.append(pickle.load(f)) except (EOFError, pickle.UnpicklingError): pass return data def PickleObjectToFile(data, full_path, date_segmentation): """Append one pickled flight to the end of binary file. Args: data: data to pickle full_path: name (potentially including path) of the pickled file date_segmentation: boolean indicating whether the date string yyyy-mm-dd should be prepended to the file name in full_path based on the current date, so that pickled files are segmented by date. <----SKIPPED LINES----> if date_segmentation: full_path = PrependFileName(full_path, EpochDisplayTime(time.time(), '%Y-%m-%d-')) try: with open(full_path, 'ab') as f: f.write(pickle.dumps(data)) except IOError: Log('Unable to append pickle ' + full_path) def UpdateAircraftList(persistent_nearby_aircraft, current_nearby_aircraft, now): """Identifies newly seen aircraft and removes aircraft that haven't been seen recently. Updates persistent_nearby_aircraft as follows: flights that have been last seen more than PERSISTENCE_SECONDS seconds ago are removed; new flights in current_nearby_aircraft are added. Also identifies newly-seen aircraft and updates the last-seen timestamp of flights that have been seen again. Args: persistent_nearby_aircraft: dictionary where keys are flight number / squawk tuples, and the values are the time the flight was last seen. current_nearby_aircraft: dictionary where keys are flight numbers / squawk tuples, and the values are themselves dictionaries with key-value pairs about that flight, with at least one of the kv-pairs being the time the flight was seen. now: the timestamp of the flights in the current_nearby_aircraft. Returns: A list of newly-nearby flight identifiers (i.e.: 2-tuple of flight number / squawk). """ newly_nearby_flight_identifiers = [] for flight_identifier in current_nearby_aircraft: flight_number = flight_identifier[0] # Only add it to the list once we've received a flight number if flight_identifier not in persistent_nearby_aircraft and flight_number: newly_nearby_flight_identifiers.append(flight_identifier) persistent_nearby_aircraft[flight_identifier] = now flights_to_delete = [] for flight_identifier in persistent_nearby_aircraft: if (flight_identifier not in current_nearby_aircraft and (now - persistent_nearby_aircraft[flight_identifier]) > PERSISTENCE_SECONDS): flights_to_delete.append(flight_identifier) for flight_identifier in flights_to_delete: del persistent_nearby_aircraft[flight_identifier] return newly_nearby_flight_identifiers def ScanForNewFlights(persistent_nearby_aircraft, persistent_path, log_jsons): """Determines if there are any new aircraft in the radio message. The radio is continuously dumping new json messages to the Raspberry pi with all the flights currently observed. This function picks up the latest radio json, and for any new nearby flights - there should generally be at most one new flight on each pass through - gets additional flight data from FlightAware and augments the flight definition with the relevant fields to keep. Args: persistent_nearby_aircraft: dictionary where keys are flight numbers, and the values are the time the flight was last seen. persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. log_jsons: boolean indicating whether we should pickle the JSONs. Returns: A tuple: - updated persistent_nearby_aircraft - (possibly empty) dictionary of flight attributes of the new flight upon its first observation. - the time of the radio observation if present; None if no radio dump - a dictionary of attributes about the dump itself (i.e.: # of flights; furthest observed flight, etc.) - persistent_path, a data structure containing past details of a flight's location as described in ParseDumpJson """ flight_details = {} now = time.time() if SIMULATION: (dump_json, json_time) = DUMP_JSONS[SIMULATION_COUNTER] else: dump_json = ReadFile(DUMP_JSON_FILE, log_exception=True) json_desc_dict = {} current_nearby_aircraft = {} if dump_json: (current_nearby_aircraft, now, json_desc_dict, persistent_path) = ParseDumpJson( dump_json, persistent_path) if not SIMULATION and log_jsons: PickleObjectToFile((dump_json, now), PICKLE_DUMP_JSON_FILE, True) newly_nearby_flight_identifiers = UpdateAircraftList( persistent_nearby_aircraft, current_nearby_aircraft, now) if newly_nearby_flight_identifiers: if len(newly_nearby_flight_identifiers) > 1: newly_nearby_flight_identifiers_str = ', '.join(newly_nearby_flight_identifiers) newly_nearby_flight_details_str = '\n'.join( [str(current_nearby_aircraft[f]) for f in newly_nearby_flight_identifiers]) Log('Multiple newly-nearby flights: %s\n%s' % ( newly_nearby_flight_identifiers_str, newly_nearby_flight_details_str)) flight_identifier = newly_nearby_flight_identifiers[0] flight_aware_json = {} if SIMULATION: json_times = [j[1] for j in FA_JSONS] if json_time in json_times: flight_aware_json = FA_JSONS[json_times.index(json_time)][0] elif flight_identifier[0]: flight_number = flight_identifier[0] flight_aware_json = GetFlightAwareJson(flight_number) if flight_aware_json: UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, False) else: Log('No json returned from Flightaware for flight: %s' % flight_number) UpdateStatusLight(GPIO_ERROR_FLIGHT_AWARE_CONNECTION, True) flight_details = {} if flight_aware_json: flight_details = ParseFlightAwareJson(flight_aware_json) if not SIMULATION and log_jsons: PickleObjectToFile((flight_aware_json, now), PICKLE_FA_JSON_FILE, True) # Augment FlightAware details with radio / radio-derived details flight_details.update(current_nearby_aircraft[flight_identifier]) # Augment with the past location data; the [1] is because recall that # persistent_path[key] is actually a 2-tuple, the first element being # the most recent time seen, and the second element being the actual # path. But we do not need to keep around the most recent time seen any # more. flight_details['persistent_path'] = persistent_path[flight_identifier][1] return ( persistent_nearby_aircraft, flight_details, now, json_desc_dict, persistent_path) def DescribeDumpJson(parsed): """Generates a dictionary with descriptive attributes about the dump json file. Args: parsed: The parsed json file. Returns: Dictionary with attributes about radio range, number of flights seen, etc. """ json_desc_dict = {} json_desc_dict['now'] = parsed['now'] aircraft = [a for a in parsed['aircraft'] if a['seen'] < PERSISTENCE_SECONDS] json_desc_dict['radio_range_flights'] = len(aircraft) aircraft_with_pos = [a for a in aircraft if 'lat' in a and 'lon' in a] current_distances = [HaversineDistanceMeters( HOME, (a['lat'], a['lon'])) for a in aircraft_with_pos] current_distances = [ d * FEET_IN_METER / FEET_IN_MILE for d in current_distances if d is not None] if current_distances: json_desc_dict['radio_range_miles'] = max(current_distances) return json_desc_dict def MergedIdentifier(proposed_id, existing_ids): flight_number, squawk = proposed_id def CheckPartialMatch(value, position): if value is not None: return [e for e in existing_ids if e[position] == value and e != proposed_id] return [] matches = CheckPartialMatch(flight_number, 0) matches.extend(CheckPartialMatch(squawk, 1)) if not matches: return proposed_id, None if not flight_number and matches: # arbitrarily choose alpha-first non-null flight_number matching_flight_numbers = [m[0] for m in matches if m[0] is not None] if matching_flight_numbers: flight_number = sorted(matching_flight_numbers)[0] if not squawk and matches: # arbitrarily choose alpha-first non-null squawk matching_squawks = [m[1] for m in matches if m[1] is not None] if matching_squawks: squawk = sorted(matching_squawks)[0] id_to_use = (flight_number, squawk) verbose = False if verbose: message_parts = [] if proposed_id != id_to_use: message_parts.append('Proposed id %s replaced with %s' % (proposed_id, id_to_use)) if id_to_use in matches: matches.remove(id_to_use) if matches: message_parts.append('%s should be merged with %s' % (matches, id_to_use)) message = '; '.join(message_parts) Log(message) return id_to_use, matches def MergePersistentPath(id_to_use, ids_to_merge, persistent_path): verbose = False path = [] timestamps = [] if id_to_use in persistent_path and id_to_use not in ids_to_merge: ids_to_merge.append(id_to_use) if verbose: print('ids_to_merge: %s' % ids_to_merge) for i in ids_to_merge: if verbose: print('path for %s: %s' % (i, persistent_path[i])) print('') timestamps.append(persistent_path[i][0]) path.extend(persistent_path[i][1]) persistent_path.pop(i) persistent_path[id_to_use] = (max(timestamps), sorted(path, key=lambda p: p['now'])) if verbose: print('new path path for %s: %s' % (id_to_use, persistent_path[id_to_use])) print('='*80) print('') return persistent_path def ParseDumpJson(dump_json, persistent_path): """Identifies all airplanes within given distance of home from the dump1090 file. Since the dump1090 json will have messages from all flights that the antenna has picked up, we want to keep only flights that are within a relevant distance to us, and also to extract from the full set of data in the json to just the relevant fields for additional analysis. While most flights have both a squawk and a flight number, enough are missing one only for it to appear later to want to use a 2-tuple of both as an identifier, merging flights if they share a common non-null flight number and/or squawk, as the persistent identifier across time. Args: dump_json: The text representation of the json message from dump1090-mutability persistent_path: dictionary where keys are flight numbers, and the values are a sequential list of the location-attributes in the json file; allows for tracking the flight path over time. Returns: Return tuple: - dictionary of all nearby planes, where keys are flight numbers (i.e.: 'SWA7543'), and the value is itself a dictionary of attributes. - time stamp in the json file. - dictionary of attributes about the radio range - persistent dictionary of the track of recent flights, where keys are the flight numbers and the value is a tuple, the first element being when the flight was last seen in this radio, and the second is a list of dictionaries with past location info from the radio where it's been seen, i.e.: d[flight] = (timestamp, [{}, {}, {}]) """ parsed = json.loads(dump_json) now = parsed['now'] nearby_aircraft = {} # Build dictionary summarizing characteristics of the dump_json itself json_desc_dict = DescribeDumpJson(parsed) for aircraft in parsed['aircraft']: simplified_aircraft = {} simplified_aircraft['now'] = now # flight_number flight_number = aircraft.get('flight') if flight_number: flight_number = flight_number.strip() # squawk squawk = aircraft.get('squawk') if squawk: squawk = squawk.strip() identifier = (flight_number, squawk) # merge any duplicate flights: since the id for nearby_aircraft & persistent_path is # the 2-tuple (flight_number, squawk), it's possible for a flight to add or drop # one of those two elements over time as the radio signal comes in / falls out. # Let's keep the identifier as the non-null values as soon as one is seen. id_to_use, ids_to_merge = MergedIdentifier(identifier, persistent_path.keys()) # Now we need to rename any flight paths with that partial identifier to have # the correct new merged_identifier; bu if ids_to_merge: persistent_path = MergePersistentPath(id_to_use, ids_to_merge, persistent_path) if 'lat' in aircraft and 'lon' in aircraft: lat = aircraft['lat'] lon = aircraft['lon'] if isinstance(lat, numbers.Number) and isinstance(lon, numbers.Number): simplified_aircraft['lat'] = lat simplified_aircraft['lon'] = lon altitude = aircraft.get('altitude', aircraft.get('alt_baro')) if isinstance(altitude, numbers.Number): simplified_aircraft['altitude'] = altitude speed = aircraft.get('speed', aircraft.get('gs')) if speed is not None: simplified_aircraft['speed'] = speed vert_rate = aircraft.get('vert_rate', aircraft.get('baro_rate')) if vert_rate is not None: simplified_aircraft['vert_rate'] = vert_rate track = aircraft.get('track') if isinstance(track, numbers.Number): min_meters = MinMetersToHome((lat, lon), track) simplified_aircraft['track'] = track simplified_aircraft['min_feet'] = min_meters * FEET_IN_METER if HaversineDistanceMeters(HOME, (lat, lon)) < MIN_METERS: nearby_aircraft[id_to_use] = simplified_aircraft if flight_number: nearby_aircraft[id_to_use]['flight_number'] = flight_number if squawk: nearby_aircraft[id_to_use]['squawk'] = squawk # aircraft classification: # https://github.com/wiedehopf/adsb-wiki/wiki/ADS-B-aircraft-categories category = aircraft.get('category') if category is not None: simplified_aircraft['category'] = category # keep all that track info - once we start reporting on a nearby flight, it will # become part of the flight's persistent record. Also, note that as we are # building a list of tracks for each flight, and we are later assigning the # flight dictionary to point to the list, we just simply need to continue # updating this list to keep the dictionary up to date (i.e.: we don't need # to directly touch the flights dictionary in main). (last_seen, current_path) = persistent_path.get(id_to_use, (None, [])) if ( # flight position has been updated with this radio signal not current_path or simplified_aircraft.get('lat') != current_path[-1].get('lat') or simplified_aircraft.get('lon') != current_path[-1].get('lon')): current_path.append(simplified_aircraft) persistent_path[id_to_use] = (now, current_path) # if the flight was last seen too far in the past, remove the track info for f in list(persistent_path.keys()): (last_seen, current_path) = persistent_path[f] if last_seen < now - PERSISTENCE_SECONDS: persistent_path.pop(f) return (nearby_aircraft, now, json_desc_dict, persistent_path) def GetFlightAwareJson(flight_number): """Scrapes the text json message from FlightAware for a given flight number. Given a flight number, loads the corresponding FlightAware webpage for that flight and extracts the relevant script that contains all the flight details from that page. Args: flight_number: text flight number (i.e.: SWA1234) Returns: Text representation of the json message from FlightAware. """ url = 'https://flightaware.com/live/flight/' + flight_number try: response = requests.get(url) except requests.exceptions.RequestException as e: <----SKIPPED LINES----> flights: the list of the raw data from which the insights will be generated, where the flights are listed in order of observation - i.e.: flights[0] was the earliest seen, and flights[-1] is the most recent flight for which we are attempting to generate an insight. percent_size_difference: the minimum size (i.e.: length) difference for the insight to warrant including the size details. Returns: Printable string message; if no message or insights to generate, then an empty string. """ message = '' this_flight = flights[-1] this_flight_number = DisplayFlightNumber(this_flight) last_seen = [f for f in flights[:-1] if DisplayFlightNumber(f) == this_flight_number] # Last time this same flight flew a materially different type of aircraft if last_seen and 'flight_number' in this_flight: last_flight = last_seen[-1] last_aircraft = last_flight.get('aircraft_type_friendly') last_aircraft_length = AIRCRAFT_LENGTH.get(last_aircraft, 0) this_aircraft = this_flight.get('aircraft_type_friendly') this_aircraft_length = AIRCRAFT_LENGTH.get(this_aircraft, 0) this_likely_commercial_flight = ( this_flight.get('origin_iata') and this_flight.get('destination_iata')) if this_likely_commercial_flight and this_aircraft and not this_aircraft_length: Log('%s used in a flight with defined origin & destination but yet is ' 'missing length details' % this_aircraft, file=LOGFILE) likely_same_commercial_flight = ( last_flight.get('origin_iata') == this_flight.get('origin_iata') and last_flight.get('destination_iata') == this_flight.get('destination_iata') and last_flight.get('airline_call_sign') == this_flight.get('airline_call_sign')) this_aircraft_bigger = False last_aircraft_bigger = False if (likely_same_commercial_flight and this_aircraft_length > last_aircraft_length * (1 + percent_size_difference)): this_aircraft_bigger = True comparative_text = 'larger' elif (likely_same_commercial_flight and last_aircraft_length > this_aircraft_length * (1 + percent_size_difference)): <----SKIPPED LINES----> min_group_qty=5, percentile_low=10, percentile_high=90) # we only want to do this if we're already at ~75% of the number of flights we'd # expect to see for the hour flight_hours = {} for flight in flights: if flights[-1]['now'] - flight['now'] < 8.5 * SECONDS_IN_DAY and DisplayTime( flight, '%-I%p') == DisplayTime(flights[-1], '%-I%p'): flight_hours[DisplayTime(flight, '%-d')] = flight_hours.get( DisplayTime(flight, '%-d'), 0) + 1 min_this_hour_flights = max(3, 0.75 * max(flight_hours.values())) # Once we've commented on the insights for an hour or day, we don't want to do it again hour_delay_frequency_flag = FLAG_INSIGHT_HOUR_DELAY_FREQUENCY hour_delay_time_flag = FLAG_INSIGHT_HOUR_DELAY_TIME date_delay_frequency_flag = FLAG_INSIGHT_DATE_DELAY_FREQUENCY date_delay_time_flag = FLAG_INSIGHT_DATE_DELAY_TIME for flight in flights[:-1]: insights = flight.get('insight_types', []) this_hour = DisplayTime(flights[-1], '%x %-I%p') this_day = DisplayTime(flights[-1], '%x') if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_FREQUENCY in insights): hour_delay_frequency_flag = None if (this_hour == DisplayTime(flight, '%x %-I%p') and FLAG_INSIGHT_HOUR_DELAY_TIME in insights): hour_delay_time_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_FREQUENCY in insights): date_delay_frequency_flag = None if (this_day == DisplayTime(flight, '%x') and FLAG_INSIGHT_DATE_DELAY_TIME in insights): date_delay_time_flag = None def TodaysHour(f): f_date = DisplayTime(f, '%x') f_hour = DisplayTime(f, '%-I%p') if f_date == DisplayTime(flights[-1], '%x'): return '%s flights today' % f_hour <----SKIPPED LINES----> for flight in flights: flight[function[0]] = function[1](flight) # these functions return dictionary of values functions = [ lambda f: FlightAnglesSecondsElapsed(f, 0, '_00s'), lambda f: FlightAnglesSecondsElapsed(f, 10, '_10s'), lambda f: FlightAnglesSecondsElapsed(f, 20, '_20s'), DisplayDepartureTimes] for function in functions: for flight in flights: flight.update(function(flight)) all_keys = set() for f in flights: all_keys.update(f.keys()) all_keys = list(all_keys) all_keys.sort() keys_logical_order = [ 'now_date', 'now_time', 'now_datetime', 'now', 'flight_number', 'squawk', 'origin_iata', 'destination_iata', 'altitude', 'min_feet', 'vert_rate', 'speed', 'distance', 'delay_seconds', 'airline_call_sign', 'aircraft_type_friendly', 'azimuth_degrees_00s', 'azimuth_degrees_10s', 'azimuth_degrees_20s', 'altitude_degrees_00s', 'altitude_degrees_10s', 'altitude_degrees_20s', 'ground_distance_feet_00s', 'ground_distance_feet_10s', 'ground_distance_feet_20s', 'crow_distance_feet_00s', 'crow_distance_feet_10s', 'crow_distance_feet_20s'] for key in all_keys: if key not in keys_logical_order: keys_logical_order.append(key) max_length = 32000 def ExcelFormatValue(v): s = str(v) if len(s) > max_length: # maximum Excel cell length is 32767 characters s = '%d character field truncated to %d characters: %s' % ( len(s), max_length, s[:max_length]) return s f = open(filename, 'w') f.write(','.join(keys_logical_order)+'\n') for flight in flights: f.write(','.join( ['"'+ExcelFormatValue(flight.get(k))+'"' for k in keys_logical_order])+'\n') f.close() def SimulationSetup(): """Updates global variable file names and loads in JSON data for simulation runs.""" # Clear file so that shell tail -f process can continue to point to same file def ClearFile(filename): if os.path.exists(filename): with open(filename, 'w') as f: f.write('') global SIMULATION SIMULATION = True global DUMP_JSONS DUMP_JSONS = UnpickleObjectFromFile(PICKLE_DUMP_JSON_FILE, True) global FA_JSONS FA_JSONS = UnpickleObjectFromFile(PICKLE_FA_JSON_FILE, True) <----SKIPPED LINES----> def PerformGracefulShutdown(queues, shutdown, reboot): """Complete the graceful shutdown process by cleaning up. Args: queues: iterable of queues shared with child processes to be closed shutdown: tuple of shared flags with child processes to initiate shutdown in children reboot: boolean indicating whether we should trigger a reboot """ reboot_msg = '' if reboot: reboot_msg = ' and rebooting' Log('Shutting down self (%d)%s' % (os.getpid(), reboot_msg)) for q in queues: q.close() for v in shutdown: # send the shutdown signal to child processes v.value = 1 if RASPBERRY_PI: RPi.GPIO.cleanup() UpdateDashboard(True) if reboot or REBOOT_SIGNAL: time.sleep(10) # wait 10 seconds for children to shut down as well os.system('sudo reboot') sys.exit() def FindRunningParents(): """Returns list of proc ids of processes with identically-named python file running. In case there are multiple children processes spawned with the same name, such as via multiprocessing, this will only return the parent id (since a killed child process will likely just be respawned). """ this_process_id = os.getpid() this_process_name = os.path.basename(sys.argv[0]) pids = [] pid_pairs = [] for proc in psutil.process_iter(): try: <----SKIPPED LINES----> args=(to_servo_q, to_main_q, shutdown[1])) return remote, servo def ValidateSingleRunning(enabled, start_function, p=None, args=()): """Restarts a new instance of multiprocessing process if not running""" if not SHUTDOWN_SIGNAL: if not enabled: if p is not None: # must have just requested a disabling of single instance args[2].value = 1 # trigger a shutdown on the single instance return None if p is None or not p.is_alive(): if p is None: Log('Process for %s starting for first time' % str(start_function)) else: Log('Process (%s) for %s died; restarting' % (str(p), str(start_function))) args[2].value = 0 # (re)set shutdown flag to allow function to run p = multiprocessing.Process(target=start_function, args=args) p.daemon = True # has been set to True #TODO p.start() return p def EnqueueArduinos(flights, json_desc_dict, configuration, to_servo_q, to_remote_q): """Send latest data to arduinos via their shared-memory queues""" last_flight = {} if flights: last_flight = flights[-1] if SIMULATION: now = json_desc_dict['now'] else: now = time.time() additional_attributes = {} today = EpochDisplayTime(now, '%x') flight_count_today = len([1 for f in flights if DisplayTime(f, '%x') == today]) additional_attributes['flight_count_today'] = flight_count_today additional_attributes['simulation'] = SIMULATION message = (last_flight, json_desc_dict, configuration, additional_attributes) try: if 'enable_servos' in configuration: to_servo_q.put(message, block=False) if 'enable_remote' in configuration: <----SKIPPED LINES----> if mtime == os.path.getmtime(f): shutil.move(tmp_f, f) else: print('Aborted: failed to bootstrap %s: file changed while in process' % full_path) return def ResetLogs(config): """Clears the non-scrolling logs if reset_logs in config.""" if 'reset_logs' in config: Log('Reset logs') for f in (STDERR_FILE, BACKUP_FILE, SERVICE_VERIFICATION_FILE): if RemoveFile(f): open(f, 'a').close() config.pop('reset_logs') config = BuildSettings(config) WriteFile(CONFIG_FILE, config) return config def CheckTemperature(): """Turn on fan if temperature exceeds threshold.""" if RASPBERRY_PI: temperature = gpiozero.CPUTemperature().temperature if temperature > TEMP_FAN_TURN_ON_CELSIUS: UpdateStatusLight(GPIO_FAN, True) elif temperature < TEMP_FAN_TURN_OFF_CELSIUS: UpdateStatusLight(GPIO_FAN, False) pin_values = {} # caches last set value def SetPinMode(): """Initialize output GPIO pins for output on Raspberry Pi.""" global pin_values if RASPBERRY_PI: RPi.GPIO.setmode(RPi.GPIO.BCM) for pin in ( GPIO_ERROR_VESTABOARD_CONNECTION, GPIO_ERROR_FLIGHT_AWARE_CONNECTION, GPIO_ERROR_ARDUINO_SERVO_CONNECTION, GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, GPIO_ERROR_BATTERY_CHARGE, GPIO_FAN, GPIO_UNUSED_1, GPIO_UNUSED_2): # Initialize some pins to start in error condition if pin in ( GPIO_ERROR_ARDUINO_SERVO_CONNECTION, GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, GPIO_UNUSED_1, GPIO_UNUSED_2): pin_values[pin[0]] = True else: pin_values[pin[0]] = False if RASPBERRY_PI: RPi.GPIO.setup(pin[0], RPi.GPIO.OUT) RPi.GPIO.output(pin[0], pin_values[pin[0]]) UpdateDashboard(pin_values[pin[0]], pin) if RASPBERRY_PI: # configure soft reset button RPi.GPIO.setup(GPIO_SOFT_RESET[0], RPi.GPIO.IN, pull_up_down=RPi.GPIO.PUD_DOWN) RPi.GPIO.setup(GPIO_SOFT_RESET[1], RPi.GPIO.OUT) RPi.GPIO.output(GPIO_SOFT_RESET[1], True) RPi.GPIO.add_event_detect(GPIO_SOFT_RESET[0], RPi.GPIO.RISING) RPi.GPIO.add_event_callback(GPIO_SOFT_RESET[0], InterruptRebootFromButton) def UpdateStatusLight(pin, value): """Sets the Raspberry Pi GPIO pin high (True) or low (False) based on value.""" global pin_values if value: msg = pin[1] else: msg = pin[2] if RASPBERRY_PI: RPi.GPIO.output(pin[0], value) if value: pin_setting = 'HIGH' relay_light_value = 'OFF' else: pin_setting = 'LOW' relay_light_value = 'ON' msg += '; RPi GPIO pin %d set to %s; relay light #%d should now be %s' % ( pin[0], pin_setting, pin[3], relay_light_value) if pin_values[pin[0]] != value: Log(msg) # log pin_values[pin[0]] = value # update cache UpdateDashboard(value, pin) def UpdateDashboard(value, subsystem=0): versions = (VERSION_MESSAGEBOARD, VERSION_ARDUINO) if subsystem: subsystem = subsystem[0] PickleObjectToFile((time.time(), subsystem, value, versions), PICKLE_DASHBOARD, True) def RemoveFile(file): """Removes a file if it exists, returning a boolean indicating if it had existed.""" if os.path.exists(file): os.remove(file) return True return False def ConfirmNewFlight(flight, flights): """Replaces last-seen flight with new flight if otherwise identical but for identifiers. Flights are identified by the radio over time by a tuple of identifiers: flight_number and squawk. Due to unknown communication issues, one or the other may not always be transmitted. However, as soon as a new flight is identified that has at least one of those identifiers, we report on it and log it to the pickle repository, etc. This function checks if the newly identified flight is indeed a duplicate of the immediate prior flight by virtue of having the same squawk and/or flight number, and further, if the paths overlap. If the paths do not overlap, then its likely that the same flight was seen some minutes apart, and should legitimately be treated as a different flight. If the new flight is an updated version, then we should replace the prior-pickled-to- disk flight and replace the last flight in flights with this new version. Args: flight: new flight to check if identical to previous flight flights: list of all flights seen so far Returns: Boolean indicating whether flight is a new (True) or an updated version (False). """ # boundary conditions if not flight or not flights: return flight last_flight = flights[-1] # flight_number and squawk are new if ( flight.get('flight_number') != last_flight.get('flight_number') and flight.get('squawk') != last_flight.get('squawk')): return True # its a returning flight... but perhaps some time later as its hovering in the area last_flight_last_seen = last_flight.get('persistent_path', [last_flight])[-1]['now'] if flight['now'] - last_flight_last_seen > PERSISTENCE_SECONDS: return True # it's not a new flight, so: # 1) replace the last flight in flights message = ( 'Flight (%s; %s) is overwriting the prior ' 'recorded flight (%s; %s) due to updated identifiers' % ( flight.get('flight_number'), flight.get('squawk'), last_flight.get('flight_number'), last_flight.get('squawk'))) flights[-1] = flight # 2) replace the last pickled record # # There is potential complication in that the last flight and the new flight # crossed into a new day, and we are using date segmentation so that the last # flight exists in yesterday's file max_days = 1 if not SIMULATION and DisplayTime(flight, '%x') != DisplayTime(last_flight, '%x'): max_days = 2 message += ( '; in repickling, we crossed days, so pickled flights that might otherwise' ' be in %s file are now all located in %s file' % ( DisplayTime(last_flight, '%x'), DisplayTime(flight, '%x'))) Log(message) saved_flights = UnpickleObjectFromFile(PICKLE_FLIGHTS, not SIMULATION, max_days=max_days) saved_flights[-1] = flight for f in saved_flights: PickleObjectToFile(f, PICKLE_FLIGHTS, not SIMULATION) return False def HeartbeatRestart(): if SIMULATION: return 0 UpdateDashboard(True) # Indicates that this wasn't running a moment before, ... UpdateDashboard(False) # ... and now it is running! return time.time() def Heartbeat(last_heartbeat_time): if SIMULATION: return last_heartbeat_time now = time.time() if now - last_heartbeat_time > HEARTBEAT_SECONDS: UpdateDashboard(False) last_heartbeat_time = now return last_heartbeat_time def VersionControl(): global VERSION_MESSAGEBOARD global VERSION_ARDUINO def MakeCopy(python_prefix): file_extension = '.py' live_name = python_prefix + '.py' live_path = os.path.join(CODE_REPOSITORY, live_name) epoch = os.path.getmtime(live_path) last_modified_suffix = EpochDisplayTime(epoch, format_string='-%Y-%m-%d-%H%M') version_name = python_prefix + last_modified_suffix + file_extension version_path = os.path.join(VERSION_REPOSITORY, version_name) shutil.copyfile(live_path, version_path) return version_name VERSION_MESSAGEBOARD = MakeCopy('messageboard') VERSION_ARDUINO = MakeCopy('arduino') def main(): """Traffic cop between incoming radio flight messages, configuration, and messageboard. This is the main logic, checking for new flights, augmenting the radio signal with additional web-scraped data, and generating messages in a form presentable to the messageboard. """ RemoveFile(LOGFILE_LOCK) VersionControl() last_heartbeat_time = HeartbeatRestart() # Since this clears log files, it should occur first before we start logging if '-s' in sys.argv: global SIMULATION_COUNTER SimulationSetup() # This flag slows down simulation time around a flight, great for debugging the arduinos simulation_slowdown = bool('-f' in sys.argv) # Redirect any errors to a log file instead of the screen, and add a datestamp if not SIMULATION: sys.stderr = open(STDERR_FILE, 'a') Log('', STDERR_FILE) Log('Starting up process %d' % os.getpid()) already_running_ids = FindRunningParents() if already_running_ids: for pid in already_running_ids: Log('Sending termination signal to %d' % pid) os.kill(pid, signal.SIGTERM) SetPinMode() configuration = ReadAndParseSettings(CONFIG_FILE) startup_time = time.time() json_desc_dict = {} flights = UnpickleObjectFromFile(PICKLE_FLIGHTS, True, max_days=MAX_INSIGHT_HORIZON_DAYS) # Clear the loaded flight of any cached data, identified by keys with a specific # suffix, since code fixes may change the values for some of those cached elements for flight in flights: for key in list(flight.keys()): if key.endswith(CACHED_ELEMENT_PREFIX): flight.pop(key) # If we're displaying just a single insight message, we want it to be something # unique, to the extent possible; this dict holds a count of the diff types of messages # displayed so far insight_message_distribution = {} # bootstrap the flight insights distribution from a list of insights on each # flight (i.e.: flight['insight_types'] for a given flight might look like # [1, 2, 7, 9], or [], to indicate which insights were identified; this then # transforms that into {0: 25, 1: 18, ...} summing across all flights. missing_insights = [] for flight in flights: if 'insight_types' not in flight: missing_insights.append( '%s on %s' % (DisplayFlightNumber(flight), DisplayTime(flight, '%x %X'))) distribution = flight.get('insight_types', []) for key in distribution: insight_message_distribution[key] = ( insight_message_distribution.get(key, 0) + 1) if missing_insights: Log('Flights missing insight distributions: %s' % ';'.join(missing_insights)) remote, servo, to_remote_q, to_servo_q, to_main_q, shutdown = InitArduinos(configuration) # used in simulation to print the hour of simulation once per simulated hour prev_simulated_hour = '' persistent_nearby_aircraft = {} # key = flight number; value = last seen epoch persistent_path = {} histogram = {} # Next up to print is index 0; this is a list of tuples: # tuple element#1: flag indicating the type of message that this is # tuple element#2: the message itself message_queue = [] next_message_time = time.time() # We repeat the loop every x seconds; this ensures that if the processing time is long, # we don't wait another x seconds after processing completes next_loop_time = time.time() + LOOP_DELAY_SECONDS # These files are read only if the version on disk has been modified more recently # than the last time it was read last_dump_json_timestamp = 0 WaitUntilKillComplete(already_running_ids) Log('Finishing initialization of %d; starting radio polling loop' % os.getpid()) while not SIMULATION or SIMULATION_COUNTER < len(DUMP_JSONS): last_heartbeat_time = Heartbeat(last_heartbeat_time) new_configuration = ReadAndParseSettings(CONFIG_FILE) CheckForNewFilterCriteria(configuration, new_configuration, message_queue, flights) configuration = new_configuration ResetLogs(configuration) # clear the logs if requested # if this is a SIMULATION, then process every diff dump. But if it isn't a simulation, # then only read & do related processing for the next dump if the last-modified # timestamp indicates the file has been updated since it was last read. tmp_timestamp = 0 if not SIMULATION: dump_json_exists = os.path.exists(DUMP_JSON_FILE) if dump_json_exists: tmp_timestamp = os.path.getmtime(DUMP_JSON_FILE) if (SIMULATION and DumpJsonChanges()) or ( not SIMULATION and dump_json_exists and tmp_timestamp > last_dump_json_timestamp): last_dump_json_timestamp = tmp_timestamp (persistent_nearby_aircraft, flight, now, json_desc_dict, persistent_path) = ScanForNewFlights( persistent_nearby_aircraft, persistent_path, configuration.get('log_jsons', False)) # because this might just be an updated instance of the previous flight as more # identifier information (squawk and or flight number) comes in, we only want to # process this if its a truly new flight new_flight_flag = ConfirmNewFlight(flight, flights) if new_flight_flag: flights.append(flight) remote, servo = RefreshArduinos( remote, servo, to_remote_q, to_servo_q, to_main_q, shutdown, flights, json_desc_dict, configuration) flight_meets_display_criteria = FlightMeetsDisplayCriteria( flight, configuration, log=True) if flight_meets_display_criteria: flight_message = (FLAG_MSG_FLIGHT, CreateMessageAboutFlight(flight)) # display the next message about this flight now! next_message_time = time.time() message_queue.insert(0, flight_message) # and delete any queued insight messages about other flights that have # not yet displayed, since a newer flight has taken precedence messages_to_delete = [m for m in message_queue if m[0] == FLAG_MSG_INTERESTING] if messages_to_delete and VERBOSE: Log( 'Deleting messages from queue due to new-found plane: %s' % <----SKIPPED LINES----> if simulated_hour != prev_simulated_hour: print(simulated_hour) prev_simulated_hour = simulated_hour histogram = ReadAndParseSettings(HISTOGRAM_CONFIG_FILE) RemoveFile(HISTOGRAM_CONFIG_FILE) # We also need to make sure there are flights on which to generate a histogram! Why # might there not be any flights? Primarily during a simulation, if there's a # lingering histogram file at the time of history restart. if histogram and not flights: Log('Histogram requested (%s) but no flights in memory' % histogram) if histogram and flights: message_queue.extend(TriggerHistograms(flights, histogram)) # check time & if appropriate, display next message from queue next_message_time = ManageMessageQueue(message_queue, next_message_time, configuration) reboot = CheckRebootNeeded(startup_time, message_queue, json_desc_dict, configuration) CheckTemperature() if not SIMULATION: time.sleep(max(0, next_loop_time - time.time())) next_loop_time = time.time() + LOOP_DELAY_SECONDS else: SIMULATION_COUNTER += 1 if simulation_slowdown: SimulationSlowdownNearFlight(flights, persistent_nearby_aircraft) if SHUTDOWN_SIGNAL: # do a graceful exit PerformGracefulShutdown((to_remote_q, to_servo_q, to_main_q), shutdown, reboot) if SIMULATION: SimulationEnd(message_queue, flights) PerformGracefulShutdown((to_remote_q, to_servo_q, to_main_q), shutdown, reboot) if __name__ == "__main__": #interrupt, as in ctrl-c signal.signal(signal.SIGINT, InterruptShutdownFromSignal) #TODO #terminate, when another instance found or via kill signal.signal(signal.SIGTERM, InterruptShutdownFromSignal) #TODO if '-i' in sys.argv: BootstrapInsightList() else: main() |