01234567890123456789012345678901234567890123456789012345678901234567890123456789
123456789101112131415161718192021222324 2526272829 30 3132 33 34 3536373839404142 434445464748495051525354555657 585960 616263 64656667 68697071727374 7576 77 7879 8081 828384858687888990 9192939495 96979899 100 101102 103104105 106107108109110111112113114115116117 118119120121122123124125126127 128129130131132133134135136 137138139 140141142143144145146147148149150151152153154 155156157158159160161162163164165166167168169170 171172173174175176177178179180181182183184185186187188 189190191192 193194195196197198199200201202203204205206207208209210211212213214215 219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 266267268269270271272273274275276277 278279280281282283284285286287 288289290291292293294295296297298299300301302303304305306 307308309310311312313314 315316 317 318319 320321322323 324325326327328329330331332333334335336 337338339340341342 343344345346347348349 350 351352353 354355356357358359360361362363364 365366367368 369 370 371372373374 375376377378379380381382383 384385386387388389390391392393394395396 397398399400401402403404 405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 491492 493494495496497498499 500501502503504505506507508509510511512513 514515516517518519520521 522523524525526 527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557 558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637 638639640 641642643644645646647648649650651652653654655656657658659660661662663 664 665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706 707708709 710711712713714715716717718719720721722723724725726727728729730731 774775776777778779780781782783784785786787788789790791792793 794 795796797798799800801802803804805806807808 809810811812813 814815816817818819820821822823824825826 827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875 876 | #!/usr/bin/python3 import contextlib import io import os import numbers import queue import random import struct import subprocess import sys import time import psutil import serial import serial.tools.list_ports from pySerialTransfer import pySerialTransfer import messageboard VERBOSE = True SIMULATE_ARDUINO = False if '-s' in sys.argv: SIMULATE_ARDUINO = True SIMULATION_FILE = 'remote_simulation.txt' #was arduino_simulation.txt SN_SERVO = '75835343130351802272' # arduino uno serial SN_REMOTE = '5583834303435111C1A0' # arduino mega serial SETTINGS_READ_TIME = 1 # read settings file once every n seconds LATEST_FLIGHT_READ_TIME = 1 # read flight file file once every n seconds COMMAND_DELAY_TIME = 1 # send a command to servos every n seconds # Serial interaction with remote COMMAND_START_CHAR = '<' COMMAND_END_CHAR = '>' KEY_NOT_PRESENT_STRING = 'N/A' MAX_CMD_LENGTH = 250 DISP_LAST_FLIGHT_NUMB_ORIG_DEST = 0 DISP_LAST_FLIGHT_AZIMUTH_ELEVATION = 1 DISP_FLIGHT_COUNT_LAST_SEEN = 2 DISP_RADIO_RANGE = 3 DISPLAY_MODE_NAMES = [ 'LAST_FLIGHT_NUMB_ORIG_DEST', 'LAST_FLIGHT_AZIMUTH_ELEVATION', 'FLIGHT_COUNT_LAST_SEEN', 'RADIO_RANGE'] SETTINGS_READ_TIME = 1 # read settings file once every n seconds LATEST_FLIGHT_READ_TIME = 1 # read flight file file once every n seconds COMMAND_DELAY_TIME = 0.2 # send a command to remote every n seconds def Log(s): print('ARDUINO: ' + s) # TODO: change back to logging class Serial(): def __init__( self, open_function, connection_tuple, baud=9600, open_timeout=5, read_format=None, write_format=None, read_timeout=0, error_pin=None): self.open_function = open_function self.connection_tuple = connection_tuple self.baud = baud self.open_timeout = open_timeout self.read_format = read_format self.write_format = write_format self.read_timeout = read_timeout self.error_pin = error_pin self.last_read = 0 self.last_receipt = 0 self.link = None if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, True) def Open(self): self.link = self.open_function( self.connection_tuple, baud=self.baud, timeout=self.open_timeout) if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, False) self.last_read = time.time() self.last_receipt = time.time() return self.link def Reopen(self, log_message=None): self.link = ReopenConnection( self.open_function, self.link, self.connection_tuple, baud=self.baud, timeout=self.open_timeout, log_message=log_message) if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, False) self.last_read = time.time() self.last_receipt = time.time() def Close(self): self.link.close() def Available(self): self.link.available() def Read(self, format_string=None): if not format_string: format_string = self.read_format try: data = Read(self.link, format_string) except OSError as e: if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, True) self.Reopen(log_message='Failed to read: %s' % e) return self.Read(format_string=format_string) self.last_read = time.time() if data: self.last_receipt = time.time() if self.read_timeout and self.last_read - self.last_receipt > self.read_timeout: if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, True) self.Reopen(log_message='Heartbeat not received in %.2f seconds' % (self.last_read - self.last_receipt)) return self.Read(format_string=format_string) return data def Write(self, values, format_string=None): if not format_string: format_string = self.write_format try: Write(self.link, values, format_string) except OSError as e: if self.error_pin: messageboard.UpdateStatusLight(self.error_pin, True) self.Reopen(log_message='Failed to write: %s' % e) self.Write(values) def RunCommand(cmd, sleep_seconds=1, log=True): conn = subprocess.Popen(cmd, shell=True) time.sleep(sleep_seconds) conn.poll() if conn.returncode is None: Log('ERROR: %s did not complete within %d seconds' % (cmd, sleep_seconds)) sys.exit() if log: Log('%s completed' % cmd) def OpenBluetooth(connection_tuple, baud=9600, timeout=5): """Attempts to make connections for a number of attempts, exiting program on fail.""" rfcomm_device, bt_mac_address = connection_tuple attempts = 5 # max number of attempts to make a connection attempt = 1 link = None dev = '/dev/rfcomm%d' % rfcomm_device desc = '%s @ %s' % (bt_mac_address, dev) if not os.path.exists(dev): RunCommand('sudo rfcomm bind %d %s' % (rfcomm_device, bt_mac_address)) while attempt <= attempts: link = pySerialTransfer.SerialTransfer(dev, baud) # We think we made a connection; lets see if we can get a receipt start = time.time() try: while time.time() - start < timeout: b = ReadBytes(link) time.sleep(0.1) # avoid a tight loop soaking up all serial / RPi resources if b: Log('Handshake received at %s by receipt of bytes %s' % (desc, b)) return link Log('No handshake received at %s after %d seconds on attempt %d' % (desc, timeout, attempt)) except OSError as e: Log('Handshake error with %s on attempt %d: %s' % (desc, attempt, e)) attempt += 1 Log('ERROR: Failed to connect to %s after %d attempts' % (bt_mac_address, attempt - 1)) sys.exit() def OpenUSB(connection_tuple=('arduino', None), baud=9600, timeout=5): """Finds a USB device with Arduino as its mfg and returns an open connection to it. Args: baud: The connection speed. timeout: Max duration in seconds that we should wait to establish a connection. Returns: Open link to the Arduino if found and opened; None otherwise. """ manufacturer, sn = connection_tuple initial_time = time.time() arduino_port = None attempted = False while not attempted or time.time() - initial_time < timeout and not arduino_port: attempted = True ports = serial.tools.list_ports.comports(include_links=False) for port in ports: port_mfg = port.manufacturer if port_mfg: port_mfg = port_mfg.lower() match_mfg = not manufacturer or (port_mfg and manufacturer.lower() in port_mfg) port_sn = port.serial_number match_sn = not sn or port_sn == sn if match_mfg and match_sn: arduino_port = port.device <----SKIPPED LINES----> if arduino_port: link = pySerialTransfer.SerialTransfer(arduino_port, baud=baud) time.sleep(2) # Need to give Arduino time before it is ready to receive else: # no USB-based matching port found raise serial.SerialException( 'ERROR: No USB port found for mfg %s and sn %s' % (manufacturer, sn)) return link def ReopenConnection( open_function, link, connection_tuple, baud=9600, timeout=3, log_message=None): """Close and reopen the serial.""" if log_message: Log(log_message) link.close() link = open_function(connection_tuple, baud=baud, timeout=timeout) return link def StuffTuple(link, t): start_pos = 0 for v in t: start_pos = link.tx_obj(v, start_pos) return start_pos def Write(link, values, format_string): """Sends the encapsulated string command on an open pySerialTransfer.""" packed_bytes = struct.pack(format_string, *values) print(format_string, values, packed_bytes) for n, b in enumerate(packed_bytes): link.txBuff[n] = b #link.tx_obj(b) link.send(len(packed_bytes)) Log('Sent %s' % str(values)) def Write2(link, values, unused_format_string): """Sends the encapsulated string command on an open pySerialTransfer.""" byte_count = StuffTuple(link, values) link.send(byte_count) Log('Sent %s' % str(values)) def ReadBytes(link): read_bytes = [] if link.available(): if link.status < 0: raise serial.SerialException('ERROR: %s' % link.status) read_bytes = [] for index in range(link.bytesRead): read_bytes.append(link.rxBuff[index]) return read_bytes def Unpack(read_bytes, format_string): try: data = struct.unpack(format_string, bytearray(read_bytes)) except struct.error as e: # exception desc is missing some key detail, so re-raise raise struct.error( '%s but only %d bytes provided (%s)' % (e, len(read_bytes), read_bytes)) data = [str(d, 'ascii').split('\x00')[0] if isinstance(d, bytes) else d for d in data] return data def Read(link, format_string): read_bytes = ReadBytes(link) data = () if read_bytes: data = Unpack(read_bytes, format_string) return data def AzimuthAltitude(flight): """Provides current best-estimate location details given last known position. Given a flight dictionary, this determines the plane's best estimate for current location using its last-known position in the flight's lat / lon / speed / altitude / and track. Those attributes may have already been updated by messageboard using a more recently obtained radio signal from dump1090 than that in the canonical location, and if so, key flight_loc_now indicates the time at which those locations are current as of. Args: flight: dictionary of flight attributes. Returns: Returns a tuple of the azimuth and altitude, in degrees, at the current system time. """ geo_time = flight.get('flight_loc_now') lat = flight.get('lat') lon = flight.get('lon') speed = flight.get('speed') altitude = flight.get('altitude') track = flight.get('track') unused_vertrate = flight.get('vertrate') if all([isinstance(x, numbers.Number) for x in (lat, lon, speed, altitude, track)]): elapsed_time = time.time() - geo_time meters_traveled = messageboard.MetersTraveled(speed, elapsed_time) new_position = messageboard.TrajectoryLatLon((lat, lon), meters_traveled, track) unused_distance = messageboard.HaversineDistanceMeters(new_position, messageboard.HOME) angles = messageboard.Angles( messageboard.HOME, messageboard.HOME_ALT, new_position, altitude / messageboard.FEET_IN_METER) azimuth = angles['azimuth_degrees'] altitude = angles['altitude_degrees'] return (azimuth, altitude) return None def DrainQueue(q): while not q.empty(): value = q.get() return value def InitialMessageValues(q): v = DrainQueue(q) if v: return v return {}, {} def ServoMain(to_arduino_q, unused_to_parent_q): Log('Process started with process id %d' % os.getpid()) link = Serial( OpenBluetooth, (RFCOMM_NUMBER_SERVO, HC05_MAC_ADDRESS_SERVO), read_timeout=5, error_pin=messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION) link.Open() servo_precision = 1 #servos = OpenUSB(sn=SN_SERVO) flight = {} altitude = 0 azimuth = 0 flight, json_desc_dict = InitialMessageValues(to_arduino_q) while True: if not to_arduino_q.empty(): flight, unused_json_desc_dict = to_arduino_q.get() link.Read('?') # simple ack message sent by servos current_angles = AzimuthAltitude(flight) if current_angles: azimuth_change = azimuth - current_angles[0] altitude_change = altitude - current_angles[1] if abs(azimuth_change) > servo_precision or abs(altitude_change) > servo_precision: (azimuth, altitude) = current_angles values = (azimuth, altitude) link.Write(values) time.sleep(COMMAND_DELAY_TIME) def SendAndWaitAck( remote, str_to_send, signature=None, timeout_time=3, max_send_attempts=3): """Sends a string to the open remote and optionally verifies receipt via a response. This sends a string to the remote. If a response is requested, this will attempt potentially transmits as requested, waiting after each transmit some number of seconds for a response that includes at minimum the key-value pair ack=signature (so the full response would be <ack=signature;>). Args: remote: open pySerialTransfer connection to the Arduino. str_to_send: string to send. signature: if a confirmation response is requested, the numeric value expected in that response. timeout_time: the max number of seconds per send attempt to wait for a response. max_send_attempts: the maximum number of times we will attempt to send. Returns: Boolean indicating whether the send attempt was successful (True) or not (False). """ byte_count = StuffStr(remote, str_to_send) send_attempt = 0 response = None first_send_time = time.time() if not SIMULATE_ARDUINO: while not response and send_attempt < max_send_attempts: remote.send(byte_count) send_attempt += 1 response_start_time = time.time() while not response and time.time() - response_start_time < timeout_time: response = ReadArduinoSerial(remote) response_stop_time = time.time() if VERBOSE: if response: print('Send attempts: %d; round trip %f; listening time on final receipt: %f' % ( send_attempt, (response_stop_time - first_send_time), (response_stop_time - response_start_time))) else: print('No response received after send attempt %d; total time elapsed: %f' % ( send_attempt, (response_stop_time - first_send_time))) if signature is not None: if not response: if VERBOSE: print('Acknowledgment required yet none received\n') return False d = ParseArduinoResponse(response) if d is None: if VERBOSE: print('Improperly formatted response string: %s\n' % response) return False if 'ack' not in d: if VERBOSE: print('Response does not have an acknowledgement "ack" key: %s\n' % response) return False signature_received = d['ack'] if signature_received != signature: if VERBOSE: print( 'Acknowledgement code %s does not match expected value %s\n' % (signature_received, signature)) return False return True def FloatToAlphanumericStr(x, decimals, total_length, sign=True): """Formats a float as a string without a decimal point. Since the decimal point is controlled independently on the alphanumeric display, numbers that include decimals must be sent without the decimal point. This formats a float as %+5.2f, for example - but without the decimal. Args: x: Value to format. decimals: how many digits should follow the (absent) decimal point. total_length: desired total length of the resulting string-ified number (inclusive of the absent decimal point. sign: boolean indicating whether a sign should be included for positive numbers. Returns: String as specified - for instance, 0.4 might be formatted as ' +04'. """ sign_str = '' if sign: sign_str = '+' format_string = '%' + sign_str + str(total_length) + '.' + str(decimals) + 'f' number_string = format_string % x number_string = number_string.replace('.', '') return number_string def DesiredState(settings, flight, display_mode): """Generates a dictionary defining the target state for the remote. Generates the complete set of key-value pairs that describe the desired state for the remote. Args: settings: dictionary representing the current state of the messageboard configuration. flight: dictionary describing the most recent flight. display_mode: integer specifying what display mode - and thus what attributes about the flight or other attributes - should be sent. Returns: Dictionary describing the full state desired for the remote. """ # Dictionary to send to remote d = {} d['setting_max_distance'] = settings['setting_max_distance'] d['setting_max_altitude'] = settings['setting_max_altitude'] d['setting_screen_enabled'] = 0 if 'setting_screen_enabled' in settings: d['setting_screen_enabled'] = 1 d['setting_on_time'] = settings['setting_on_time'] d['setting_off_time'] = settings['setting_off_time'] d['setting_delay'] = settings['setting_delay'] now = flight.get('now') # time flight was seen line1_decimal_mask = '' line2_decimal_mask = '' if display_mode == DISP_LAST_FLIGHT_NUMB_ORIG_DEST: # UAL1827 / SFO-LAX line1 = flight.get('flight_number', '') origin = ReplaceWithNA(flight.get('flight_origin', '')) destination = ReplaceWithNA(flight.get('flight_destination', '')) line2 = '%s-%s' % (origin, destination) elif display_mode == DISP_LAST_FLIGHT_AZIMUTH_ELEVATION: # AZM+193.1 / ALT +79.3 current_angles = AzimuthAltitude(flight) if current_angles: (azimuth, altitude) = current_angles line1 = 'AZM%s' % FloatToAlphanumericStr(azimuth, 1, 5, True) line1_decimal_mask = '00000010' line2 = 'ALT%s' % FloatToAlphanumericStr(altitude, 1, 5, True) line2_decimal_mask = '00000010' else: line1 = KEY_NOT_PRESENT_STRING line2 = KEY_NOT_PRESENT_STRING elif display_mode == DISP_FLIGHT_COUNT_LAST_SEEN: # 18 TODAY / T+ 14H line1 = '%2d TODAY' % flight.get('flight_count_today', 0) elapsed_time_str = 'UNK' if now: elapsed_time_str = SecondsToShortString(time.time() - now) line2 = 'T+ %s' % elapsed_time_str elif display_mode == DISP_RADIO_RANGE: # RNG 87MI / 9 PLANES radio_range = flight.get('radio_range_miles') radio_range_str = KEY_NOT_PRESENT_STRING if radio_range is not None: radio_range_str = '%2dMI' % round(radio_range) line1 = 'RNG %s' % radio_range_str radio_range_flights = flight.get('radio_range_flights', 0) plural = '' if radio_range_flights == 1: plural = 'S' line2 = '%d PLANE%s' % (radio_range_flights, plural) d['line1'] = line1 d['line2'] = line2 d['line1_decimal_mask'] = line1_decimal_mask d['line2_decimal_mask'] = line2_decimal_mask return d def StateToString(desired_state, current_state, ack=0): """Transforms the desired state dictionary to a command string. The dict describing key-value pairs is converted into a single command string in the following way: - key-value pairs matching that which are already known to the remote are not sent - an optional additional key-value pair of ack=random_int is added if a response from the remote is expected confirming receipt - format is transformed to an alphabetized list of <key1=value1;...;keyn=valuen;> Args: desired_state: Dictionary describing desired end state of remote. current_state: Dictionary describing current known state of remote. ack: Integer indicating whether the number of digits desired for an acknowledgement number; 0 indicates no acknowledgement requested. Returns: String as described above. """ s = {} for key in desired_state: if desired_state[key] != current_state.get(key): s[key] = desired_state[key] signature = None if ack: signature = random.randrange(1, 10**ack) s['ack'] = signature kv_str = messageboard.BuildSettings(s) cmd_str = '%s%s;%s' % (COMMAND_START_CHAR, kv_str, COMMAND_END_CHAR) if len(cmd_str) > MAX_CMD_LENGTH - 1: # the -1 is because C appends \0 to a string Log('Command to remote has len %d; max length is %d: %s' % ( len(cmd_str)+1, MAX_CMD_LENGTH, cmd_str)) return (cmd_str, signature) def ParseArduinoResponse(s): """Transforms a command string of form <key1=value1;...;keyn=valuen;> into a dict. Args: s: command string. Returns: Dictionary of the command string. """ if s[0] == COMMAND_START_CHAR and s[-1] == COMMAND_END_CHAR: return messageboard.ParseSettings(s[1:-1]) Log('Invalid remote response: %s' % s) return None def ExecuteArduinoCommand(command_str, settings, display_mode): """Executes the request as communicated in the command string. The remote may make one of the following requests: - Update a setting - (Re)display a recent flight - Display a histogram - Send information for a different display mode This function parses the command, in the form <key1=value1;...;keyn=valuen;>, and then executes as requested. Args: command_str: string response, including the start and end markers, from the remote. settings: dictionary representing the current state of the messageboard configuration. display_mode: integer specifying what display mode - and thus what attributes about the flight or other attributes - should be sent. Returns: The (potentially updated) display_mode. """ if not(command_str[0] == COMMAND_START_CHAR and command_str[-1] == COMMAND_END_CHAR): if VERBOSE: Log('Invalid remote command: %s' % command_str) print('Invalid command - missing delimiters: %s' % command_str) return display_mode command = messageboard.ParseSettings(command_str[1:-1]) # command might update a setting; see if there's a change, and if so, write to disk setting_change = False # remote sees on/off as 1/0 whereas messageboard.py & the web interface expect on/off if 'setting_screen_enabled' in command: arduino_screen_enabled = command['setting_screen_enabled'] if arduino_screen_enabled: command['setting_screen_enabled'] = 'on' else: command['setting_screen_enabled'] = 'off' setting_keys = ['setting_screen_enabled', 'setting_max_distance', 'setting_max_altitude', 'setting_on_time', 'setting_off_time', 'setting_delay'] for key in setting_keys: if key in command and command[key] != settings[key]: setting_change = True if setting_change: for key in setting_keys: if key in command: settings[key] = command[key] if VERBOSE: print( ' |-->Updated %s to %s in %s' % (key, command[key], messageboard.CONFIG_FILE)) messageboard.WriteFile(messageboard.CONFIG_FILE, messageboard.BuildSettings(settings)) # a command might request info about flight to be (re)displayed, irrespective of # whether the screen is on; if so, let's put that message at the front of the message # queue, and delete any subsequent messages in queue because presumably the button # was pushed either a) when the screen was off (so no messages in queue), or b) # because the screen was on, but the last flight details got lost after other screens! if 'last_plane' in command: messageboard.WriteFile(messageboard.LAST_FLIGHT_FILE, '') if VERBOSE: print(' |-->Requested last flight (re)display') # a command might request a histogram; simply generate and save a histogram file to disk if 'histogram' in command: histogram = {} histogram['type'] = 'messageboard' histogram['histogram'] = command['histogram'] histogram['histogram_history'] = command['histogram_history'] histogram['histogram_max_screens'] = '_1' # default value since not provided histogram['histogram_data_summary'] = 'off' # default value since not provided kv_string = messageboard.BuildSettings(histogram) messageboard.WriteFile(messageboard.HISTOGRAM_CONFIG_FILE, kv_string) if VERBOSE: print(' |-->Requested %s histogram with %s data' % ( command['histogram'], command['histogram_history'])) # a command might update us on the display mode; based on the display mode, we might # pass different attributes back to the remote if 'display_mode' in command: display_mode = command['display_mode'] if VERBOSE: print(' |-->Display mode updated to %d (%s)' % ( display_mode, DISPLAY_MODE_NAMES[display_mode])) if VERBOSE: print() return display_mode def SecondsToShortString(seconds): """Converts a number of seconds to a three-character time representation (i.e.: 87M). Converts seconds to a three character representation containing at most two digits (1-99) and one character indicating time unit (S, M, H, or D). Args: seconds: Number of seconds. Returns: String as described. """ s = round(seconds) if s < 99: return '%2dS' % s m = round(seconds / messageboard.SECONDS_IN_MINUTE) if m < 99: <----SKIPPED LINES----> return '' # which command should we pick? if randomized: index = random.randrange(len(potential_commands)) else: command_number = int(counter * fraction_command) - 1 index = command_number % len(potential_commands) return potential_commands[index] def ReplaceWithNA(s, function=None): """Replaces a messageboard not-known value with the alphanumeric not-known value.""" if s in (messageboard.KEY_NOT_PRESENT_STRING, 'None') or s is None: return KEY_NOT_PRESENT_STRING if function: return function(s) return s def RemoteMain(to_arduino_q, to_parent_q): Log('Process started with process id %d' % os.getpid()) # setting_screen_enabled: ? # setting_max_distance: h # setting_max_altitude: h # setting_on_time: h # setting_off_time: h # setting_delay: h # last_plane: ? # display_mode: h # histogram_enabled: ? # current_hist_type: 20s # current_hist_history: 20s format_string = '?hhhhh?h?20s20s' link = Serial( OpenBluetooth, (RFCOMM_NUMBER_REMOTE, HC05_MAC_ADDRESS_REMOTE), read_timeout=5, error_pin=messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION, format_string=format_string) remote = OpenUSB(sn=SN_REMOTE) current_state = {} settings_next_read = 0 flight_next_read = 0 display_mode = 1 simulation_counter = 0 flight = {} signature = None if SIMULATE_ARDUINO: potential_commands = messageboard.ReadFile(SIMULATION_FILE).splitlines() while True: # Read config settings once per second if time.time() > settings_next_read: settings = messageboard.ReadAndParseSettings(messageboard.CONFIG_FILE) settings_next_read = time.time() + SETTINGS_READ_TIME if VERBOSE: print('Read %s' % messageboard.CONFIG_FILE) if time.time() > flight_next_read: flight = messageboard.ReadAndParseSettings(messageboard.ARDUINO_FILE) flight_next_read = time.time() + LATEST_FLIGHT_READ_TIME if VERBOSE: print('Read %s' % messageboard.ARDUINO_FILE) # Open connection if not remote: remote = OpenUSB(sn=SN_REMOTE) current_state = {} if VERBOSE: print('Reopening connection') if remote: desired_state = DesiredState(settings, flight, display_mode) if desired_state != current_state: ack = 4 if SIMULATE_ARDUINO: ack = None (command_string, signature) = StateToString(desired_state, current_state, ack=ack) success = SendAndWaitAck( remote, command_string, signature=signature, timeout_time=3, max_send_attempts=3) if success: current_state.update(desired_state) if SIMULATE_ARDUINO: command = '' if potential_commands: simulation_counter += 1 command = SimulateCommand( potential_commands, simulation_counter, randomized=False) if command: print('RECD (SIM): %s' % command) else: command = ReadArduinoSerial(remote) if command: display_mode = ExecuteArduinoCommand(command, settings, display_mode) time.sleep(COMMAND_DELAY_TIME) |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
123 4 56789101112131415161718192021222324 252627282930313233343536373839404142434445464748495051 5253545556575859 60616263646566676869 707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 446447448449450451452453454455456457458459460461462463464465 466467468 469470 471 472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609 610611 612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 647648649650651 652653654655656657658659660661662663664665666667668669670671672673674675676677 678679680681 682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792 793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862 863864865866867868869870871 872873874875876877878879880881882883884885 886887888889 890891892893894895896897898899900901902903 904905906907908909910911 912913914915916917918919 920921922 923 924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958 100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099 1100 1101110211031104110511061107 11081109111011111112 111311141115111611171118111911201121 11221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147 | #!/usr/bin/python3 import numbers import os import random import struct import subprocess import sys import time import psutil import serial import serial.tools.list_ports from pySerialTransfer import pySerialTransfer import messageboard VERBOSE = True ARDUINO_LOG = 'arduino_log.txt' SERIALS_LOG = 'arduino_serials_log.txt' LOG_SERIALS = False SIMULATE_ARDUINO = False SERVO_SIMULATED_IN = 'servo_in.txt' SERVO_SIMULATED_OUT = 'servo_out.txt' REMOTE_SIMULATED_IN = 'remote_in.txt' REMOTE_SIMULATED_OUT = 'remote_out.txt' RASPBERRY_PI = psutil.sys.platform.title() == 'Linux' if RASPBERRY_PI: ARDUINO_LOG = messageboard.MESSAGEBOARD_PATH + ARDUINO_LOG SERIALS_LOG = messageboard.MESSAGEBOARD_PATH + SERIALS_LOG SERVO_SIMULATED_OUT = messageboard.MESSAGEBOARD_PATH + SERVO_SIMULATED_OUT SERVO_SIMULATED_IN = messageboard.MESSAGEBOARD_PATH + SERVO_SIMULATED_IN REMOTE_SIMULATED_OUT = messageboard.MESSAGEBOARD_PATH + REMOTE_SIMULATED_OUT REMOTE_SIMULATED_IN = messageboard.MESSAGEBOARD_PATH + REMOTE_SIMULATED_IN CONNECTION_FLAG_BLUETOOTH = 1 CONNECTION_FLAG_USB = 2 CONNECTION_FLAG_SIMULATED = 3 RASPBERRY_PI = psutil.sys.platform.title() == 'Linux' SN_SERVO = '5583834303435111C1A0' # arduino mega serial SERVO_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (2, '98:D3:11:FC:42:16')) SN_REMOTE = '75835343130351802272' # arduino uno serial REMOTE_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (1, '98:D3:91:FD:B3:C9')) MIN_ALTITUDE = 5 # below this elevation degrees, turn off the tracking if SIMULATE_ARDUINO: SERVO_CONNECTION = (CONNECTION_FLAG_SIMULATED, (SERVO_SIMULATED_IN, SERVO_SIMULATED_OUT)) REMOTE_CONNECTION = ( CONNECTION_FLAG_SIMULATED, (REMOTE_SIMULATED_IN, REMOTE_SIMULATED_OUT)) KEY_NOT_PRESENT_STRING = 'N/A' DISP_LAST_FLIGHT_NUMB_ORIG_DEST = 0 DISP_LAST_FLIGHT_AZIMUTH_ELEVATION = 1 DISP_FLIGHT_COUNT_LAST_SEEN = 2 DISP_RADIO_RANGE = 3 DISPLAY_MODE_NAMES = [ 'LAST_FLIGHT_NUMB_ORIG_DEST', 'LAST_FLIGHT_AZIMUTH_ELEVATION', 'FLIGHT_COUNT_LAST_SEEN', 'RADIO_RANGE'] WRITE_DELAY_TIME = 0.2 # write to arduino every n seconds READ_DELAY_TIME = 0.1 # read from arduino every n seconds HISTOGRAM_TYPES = ( (0, 'hour'), (1, 'day_of_month'), (2, 'origin'), (3, 'destination'), (4, 'airline'), (5, 'aircraft'), (6, 'altitude'), (7, 'bearing'), (8, 'distance'), (9, 'day_of_week'), (10, 'all')) HISTOGRAM_HISTORY = ( (0, 'today'), (1, '24h'), (2, '7d'), (3, '30d')) def Log(message, ser=None, file=ARDUINO_LOG): """Logs with additional serial-connection details if provided""" if file is None: file = messageboard.LOGFILE if ser: additional_text = str(ser) else: additional_text = 'ARDUINO' messageboard.Log('%s: %s' % (additional_text, message), file=file) class Serial(): """Serial object that allows for different connection types to be easily swapped. The Serial class allows for a persistent connection over one of several types of serial connections: USB, bluetooth, and simulation via text file. It fully encapsulates error management so that the details of reconnecting on errors are hidden, yet it does check for and recover from timeouts, dropped connections, etc. Note that to read to/from Arduino, the data sizes (in bytes) expected by struct must match that in C. However, there are some inconsistencies amongst identically- named types - for instance, a Python int is 4 bytes, whereas a C int is 2 bytes. The following mappings on types to use with a format string and C declarations is valid: - C bool (1 byte) to struct ? - C short (2 bytes) to struct h - C long (4 bytes) to struct l - C float (4 bytes) to struct f - C char[] (1 byte per character) to struct s Struct formats: https://docs.python.org/3/library/struct.html Arduino types: https://robotresearchlab.com/2016/11/14/variable-data-types/ """ def __init__( self, connection_type, connection_tuple, baud=9600, open_timeout=5, read_format=None, write_format=None, read_timeout=0, error_pin=None, to_parent_q=None, name=None): """Creates a new instance of the Serial class. Args: connection_type: This identifies whether we are connecting with bluetooth, usb, or via a simulated connection; based on this, we identify an opening method, which is then called as open_function(connection_tuple, baud=baud, timeout=open_timeout) connection_tuple: A tuple of connection details specific to how the serial object connects - i.e.: a mac address, or a port name, etc. baud: Baud rate for the connection. open_timeout: The number of seconds we should wait on opening a new connection before timing out. read_format: Since reads from a serial device are often using the same struct.pack format_string for all calls, this allows that to be instantiated once and then referenced as needed. write_format: Similar concept to read_format, but for sending messages to the serial device. read_timeout: If the serial device is providing a heartbeat, this accelerates the identification of a dropped connection by trying to reconnect as soon as the time difference between the last non-empty read, and the last read attempt, exceeds this many seconds. Note, however, that we may not necessarily be able to recover more quickly, based on timeouts in the underlying libraries and systems; this can be disabled by setting it to 0. error_pin: If set, the messageboard GPIO pin is set to high whenever we have a failed / non-open connection, and low whenever we believe we have reconnected. to_parent_q: message queue to send status updates to parent name: an optional text string used in some logging to help identify which serial connection the log message is associated with. """ self.connection_type = connection_type self.connection_tuple = connection_tuple self.baud = baud self.open_timeout = open_timeout self.read_format = read_format self.write_format = write_format self.read_timeout = read_timeout self.error_pin = error_pin self.to_parent_q = to_parent_q self.last_read = 0 self.last_receipt = 0 self.reset_flag = True self.link = None self.name = name self.__simulated_reads__ = None if self.connection_type == CONNECTION_FLAG_BLUETOOTH: self.open_function = OpenBluetooth elif self.connection_type == CONNECTION_FLAG_USB: self.open_function = OpenUSB elif self.connection_type == CONNECTION_FLAG_SIMULATED: self.open_function = None if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True))) self.start_time = time.time() def __str__(self): return '%s @ %s opened @ %s' % ( self.name, str(self.connection_tuple), messageboard.EpochDisplayTime(self.start_time)) def Open(self): """Opens an instantiated serial connection for reading and writing.""" if self.connection_type == CONNECTION_FLAG_SIMULATED: lines = [] if os.path.exists(self.connection_tuple[0]): with open(self.connection_tuple[0], 'r') as f: for line in f: if line.strip(): lines.append(eval(line)) # pylint: disable=W0123 else: Log('File %s does not exist for simulated commands to Arudino' % self.connection_tuple[0], self.link) self.__simulated_reads__ = lines # clear out file so that shell tail -f process can continue to point to same file with open(self.connection_tuple[1], 'w') as f: f.write('') if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, False))) return self.link = self.open_function( self.connection_tuple, baud=self.baud, timeout=self.open_timeout) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, False))) self.last_read = time.time() self.last_receipt = time.time() self.reset_flag = True def Reopen(self, log_message=None): """Closes and reopens a link, optionally logging a message.""" if self.connection_type == CONNECTION_FLAG_SIMULATED: raise NotImplementedError('Not implemented for simulations') self.link = ReopenConnection( self.open_function, self.link, self.connection_tuple, baud=self.baud, timeout=self.open_timeout, log_message=log_message) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, False))) self.reset_flag = True self.last_read = time.time() self.last_receipt = time.time() def Close(self): """Closes an open serial connection.""" if self.connection_type == CONNECTION_FLAG_SIMULATED: return self.link.close() if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True))) def Available(self): """Calls self.link.available().""" if self.connection_type == CONNECTION_FLAG_SIMULATED: raise NotImplementedError('Not implemented for simulations') self.link.available() def Read(self, format_string=None): """Reads from an open serial. Reads from an open serial values as identified in the format_string provided here, or if not provided in this call, as saved on the Serial instance. If an OSError exception is detected, or if this read, in failing to return non-empty results, means that the heartbeat timeout time has elapsed, this method will attempt to reopen the connection. Args: format_string: String of the form expected by struct.pack Returns: Tuple of values matching that as identified in format_string. """ if self.connection_type == CONNECTION_FLAG_SIMULATED: if ( self.__simulated_reads__ and time.time() - self.start_time > self.__simulated_reads__[0][0]): # time for next next_line = self.__simulated_reads__.pop(0) return next_line[1] return () if not format_string: format_string = self.read_format try: data = Read(self.link, format_string) except OSError as e: if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True))) self.Reopen(log_message='Failed to read: %s' % e) return self.Read(format_string=format_string) self.last_read = time.time() if data: self.last_receipt = time.time() if LOG_SERIALS: ts = time.time() - self.start_time str_data = str(['%7.2f' % d if isinstance(d, float) else str(d) for d in data]) with open(SERIALS_LOG, 'a') as f: f.write('%10.3f RECD@%s: %s\n' % (ts, self.name, str_data)) if self.read_timeout and self.last_read - self.last_receipt > self.read_timeout: if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True))) self.Reopen(log_message='Heartbeat not received in %.2f seconds' % (self.last_read - self.last_receipt)) return self.Read(format_string=format_string) return data def Write(self, values, format_string=None): """Writes to an open serial. Writes to an open serial values as identified in the format_string provided here, or if not provided in this call, as saved on the Serial instance. If an OSError exception is detected, this method will attempt to reopen the connection. Args: values: tuple of values to send matching that as identified in format_string. format_string: String of the form expected by struct.pack """ ts = time.time() - self.start_time str_values = str(['%7.2f' % v if isinstance(v, float) else str(v) for v in values]) if self.connection_type == CONNECTION_FLAG_SIMULATED: with open(self.connection_tuple[1], 'a') as f: f.write('%10.3f: %s\n' % (ts, str_values)) return if not format_string: format_string = self.write_format try: Write(self.link, values, format_string) except OSError as e: if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True))) self.Reopen(log_message='Failed to write: %s' % e) self.Write(values) if LOG_SERIALS: with open(SERIALS_LOG, 'a') as f: f.write('%10.3f SENT@%s: %s\n' % (ts, self.name, str_values)) def HasReset(self): """Indicates exactly oncewhether the serial connection has reset since last called.""" if self.connection_type == CONNECTION_FLAG_SIMULATED: raise NotImplementedError('Not implemented for simulations') flag = self.reset_flag self.reset_flag = False return flag def RunCommand(cmd, sleep_seconds=1, log=True): """Runs shell command, checking if it completed (perhaps with errors) within timeout.""" conn = subprocess.Popen(cmd, shell=True) time.sleep(sleep_seconds) conn.poll() if conn.returncode is None: Log('ERROR: %s did not complete within %d seconds' % (cmd, sleep_seconds)) sys.exit() if log: Log('%s completed' % cmd) def OpenBluetooth(connection_tuple, baud=9600, timeout=5): """Attempts to open bluetooth for a number of attempts, exiting program on fail. This may fail due to a missing /dev/rfcomm# entry and inability to create new one (or an existing one bound to same device number but pointing to a different serial connection), expectation of a handshake but lack of receipt of one, or timeouts in general before receipt. Args: connection_tuple: A 2-tuple of the rfcomm_device number (i.e.: the 1 in /dev/rfcomm1) and mac_address of the bluetooth radio; both must be provided. baud: speed of the connection. timeout: seconds after a connection is established that this polls for a heartbeat before failing; a value of zero indicates no handshake needed. Returns: An open pySerialTransfer.SerialTransfer link. """ rfcomm_device, bt_mac_address = connection_tuple attempts = 5 # max number of attempts to make a connection attempt = 1 link = None dev = '/dev/rfcomm%d' % rfcomm_device desc = '%s @ %s' % (bt_mac_address, dev) if not os.path.exists(dev): RunCommand('sudo rfcomm bind %d %s' % (rfcomm_device, bt_mac_address)) while attempt <= attempts: link = pySerialTransfer.SerialTransfer(dev, baud) # We think we made a connection; lets see if we can get a receipt start = time.time() if not timeout: return link try: while time.time() - start < timeout: b = ReadBytes(link) time.sleep(0.1) # avoid a tight loop soaking up all serial / RPi resources if b: Log('Handshake received at %s by receipt of bytes %s' % (desc, b)) return link Log('No handshake received at %s after %d seconds on attempt %d' % (desc, timeout, attempt)) except OSError as e: Log('Handshake error with %s on attempt %d: %s' % (desc, attempt, e)) attempt += 1 Log('ERROR: Failed to connect to %s after %d attempts' % (bt_mac_address, attempt - 1)) sys.exit() def OpenUSB(connection_tuple=('arduino', None), baud=9600, timeout=5): """Attempts to open USB for a number of seconds, exiting program on fail. This may fail due to lack of a plugged-in serial device matching the given definition. Args: connection_tuple: A 2-tuple of the manufacturer and the serial number of the expected device to connect with; either one may be missing (by providing a None value). baud: speed of the connection. timeout: seconds polling for the device matching the connection_tuple before fail on timeout. Raises: serial.SerialException: raised if no serial matching given attributes found Returns: An open pySerialTransfer.SerialTransfer link. """ manufacturer, sn = connection_tuple initial_time = time.time() arduino_port = None attempted = False while not attempted or time.time() - initial_time < timeout and not arduino_port: attempted = True ports = serial.tools.list_ports.comports(include_links=False) for port in ports: port_mfg = port.manufacturer if port_mfg: port_mfg = port_mfg.lower() match_mfg = not manufacturer or (port_mfg and manufacturer.lower() in port_mfg) port_sn = port.serial_number match_sn = not sn or port_sn == sn if match_mfg and match_sn: arduino_port = port.device <----SKIPPED LINES----> if arduino_port: link = pySerialTransfer.SerialTransfer(arduino_port, baud=baud) time.sleep(2) # Need to give Arduino time before it is ready to receive else: # no USB-based matching port found raise serial.SerialException( 'ERROR: No USB port found for mfg %s and sn %s' % (manufacturer, sn)) return link def ReopenConnection( open_function, link, connection_tuple, baud=9600, timeout=3, log_message=None): """Close and reopen the serial.""" if log_message: Log(log_message) link.close() link = open_function(connection_tuple, baud=baud, timeout=timeout) return link def Write(link, values, format_string): """Sends the encapsulated string command on an open pySerialTransfer.""" packed_bytes = struct.pack(format_string, *values) for n, b in enumerate(packed_bytes): link.txBuff[n] = b link.send(len(packed_bytes)) def ReadBytes(link): """Reads the bytes at the link, returning a byte object.""" read_bytes = [] if link.available(): if link.status < 0: raise serial.SerialException('ERROR: %s' % link.status) read_bytes = [] for index in range(link.bytesRead): read_bytes.append(link.rxBuff[index]) return read_bytes def Unpack(read_bytes, format_string): """Unpacks a byte object into a tuple of values.""" try: data = struct.unpack(format_string, bytearray(read_bytes)) except struct.error as e: # exception desc is missing some key detail, so re-raise raise struct.error( '%s but %d bytes provided (%s)' % (e, len(read_bytes), read_bytes)) data = [str(d, 'ascii').split('\x00')[0] if isinstance(d, bytes) else d for d in data] return data def Read(link, format_string): """Read and unpacks the bytes in one go.""" read_bytes = ReadBytes(link) data = () if read_bytes: data = Unpack(read_bytes, format_string) return data def AzimuthAltitude(flight, now): """Provides current best-estimate location details given last known position. Given a flight dictionary, this determines the plane's best estimate for current location using its last-known position in the flight's lat / lon / speed / altitude / and track. Those attributes may have already been updated by messageboard using a more recently obtained radio signal from dump1090 than that in the canonical location, and if so, key flight_loc_now indicates the time at which those locations are current as of. Args: flight: dictionary of flight attributes. now: epoch indicating the timestamp for which the azimuth and altitude should be calculated. Returns: Returns a tuple of the azimuth and altitude, in degrees, at the current system time. """ if not flight: return None persistent_path = flight.get('persistent_path') if not persistent_path: return None most_recent_loc = persistent_path[-1] lat = most_recent_loc.get('lat') lon = most_recent_loc.get('lon') speed = most_recent_loc.get('speed') altitude = most_recent_loc.get('altitude') track = most_recent_loc.get('track') loc_now = most_recent_loc.get('now') unused_vert_rate = most_recent_loc.get('vert_rate') if not all([isinstance(x, numbers.Number) for x in (lat, lon, speed, altitude, track)]): return None elapsed_time = now - loc_now meters_traveled = messageboard.MetersTraveled(speed, elapsed_time) new_position = messageboard.TrajectoryLatLon((lat, lon), meters_traveled, track) unused_distance = messageboard.HaversineDistanceMeters(new_position, messageboard.HOME) if elapsed_time < 60 and VERBOSE: Log('flight %s\n' 'most_recent_loc: %s\n' 'persistent path data: lat: %.5f; lon: %.5f; speed: %d; track: %d; altitude: %d\n' 'now: %.5f\n' 'loc_now: %.5f\n' 'since measured data: elapsed_time: %.2f; ' 'meters_traveled: %.2f; new_position: %.5f, %.5f' % ( messageboard.DisplayFlightNumber(flight), str(most_recent_loc), lat, lon, speed, track, altitude, now, loc_now, elapsed_time, meters_traveled, *new_position)) angles = messageboard.Angles( messageboard.HOME, messageboard.HOME_ALT, new_position, altitude / messageboard.FEET_IN_METER) azimuth = angles['azimuth_degrees'] altitude = angles['altitude_degrees'] return (azimuth, altitude) def DrainQueue(q): """Empties a queue, returning the last-retrieved value.""" value = None while not q.empty(): value = q.get(block=False) return value def InitialMessageValues(q): """Initializes the arduino main processes with values from messageboard.""" v = DrainQueue(q) if v: return v return {}, {}, {}, {} def ServoMain(to_arduino_q, to_parent_q, shutdown): """Main servo controller for projecting the plane position on a hemisphere. Takes the latest flight from the to_arduino_q and converts that to the current azimuth and altitude of the plane on a hemisphere. """ Log('Process started with process id %d' % os.getpid()) # Ensures that the child can exit if the parent exits unexpectedly # docs.python.org/2/library/multiprocessing.html#multiprocessing.Queue.cancel_join_thread to_arduino_q.cancel_join_thread() to_parent_q.cancel_join_thread() # write_format: azimuth, altitude, R, G, & B intensity # read heartbeat: millis link = Serial( *SERVO_CONNECTION, read_timeout=5, error_pin=messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION, to_parent_q=to_parent_q, read_format='l', write_format='ffhhh', name='Servo') link.Open() last_flight = {} last_angles = (0, 0) flight, json_desc_dict, settings, additional_attr = InitialMessageValues(to_arduino_q) next_read = time.time() + READ_DELAY_TIME next_write = time.time() + WRITE_DELAY_TIME now = GetNow(json_desc_dict, additional_attr) while not shutdown.value: if not to_arduino_q.empty(): flight, json_desc_dict, settings, additional_attr = to_arduino_q.get(block=False) new_flight = DifferentFlights(flight, last_flight) if new_flight: Log('Flight changed: %s' % messageboard.DisplayFlightNumber(flight), ser=link) # Turn off laser so that line isn't traced while it moves to new position link.Write((*last_angles, *LASER_RGB_OFF)) last_flight = flight if time.time() >= next_read: link.Read() # simple ack message sent by servos next_read = time.time() + READ_DELAY_TIME now = GetNow(json_desc_dict, additional_attr) current_angles = AzimuthAltitude(flight, now) if current_angles and time.time() > next_write: if current_angles[1] >= settings['minimum_altitude_servo_tracking']: laser_rgb = LaserRGBFlight(flight) link.Write((*current_angles, *laser_rgb)) last_angles = current_angles # stop moving the head at this point else: link.Write((*last_angles, *LASER_RGB_OFF)) next_write = time.time() + WRITE_DELAY_TIME if not SIMULATE_ARDUINO: time.sleep(READ_DELAY_TIME) link.Close() Log('Shutdown signal received by process %d' % os.getpid(), link) to_parent_q.put(('pin', (messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION, True))) LASER_RGB_OFF = (0, 0, 0) def LaserRGBFlight(flight): """Based on flight attributes, set the laser.""" if not flight: return LASER_RGB_OFF return 1, 0, 0 def DifferentFlights(f1, f2): """True if flights same except for persistent path; False if they differ. We cannot simply check if two flights are identical by checking equality of the dicts, because a few attributes are updated after the flight is first found: - the persistent_path is kept current - cached_* attributes may be updated - the insights are generated after flight first enqueued On the other hand, we cannot check if they are identical by looking just at the flight number, because flight numbers may be unknown. Thus, this checks all attributes except the persistent path. Returns: List of the different keys, excluding the persistent_path key. """ if f1 is None and f2 is None: return [] if f1 is None: return sorted(list(f2.keys())) if f2 is None: return sorted(list(f1.keys())) excluded_keys = ['persistent_path', 'insight_types'] different_attributes = [] for key in set(f1.keys()).union(set(f2.keys())): if key not in excluded_keys and not key.startswith(messageboard.CACHED_ELEMENT_PREFIX): if f1.get(key) != f2.get(key): different_attributes.append(key) return sorted(different_attributes) def FloatToAlphanumericStr(x, decimals, total_length, sign=True): """Formats a float as a string without a decimal point. Since the decimal point is controlled independently on the alphanumeric display, numbers that include decimals must be sent without the decimal point. This formats a float as %+5.2f, for example - but without the decimal. Args: x: Value to format. decimals: how many digits should follow the (absent) decimal point. total_length: desired total length of the resulting string-ified number (inclusive of the absent decimal point. sign: boolean indicating whether a sign should be included for positive numbers. Returns: String as specified - for instance, 0.4 might be formatted as ' +04'. """ sign_str = '' if sign: sign_str = '+' format_string = '%' + sign_str + str(total_length) + '.' + str(decimals) + 'f' number_string = format_string % x number_string = number_string.replace('.', '') return number_string def DictToValueTuple(d, key_tuple, format_tuple): """Converts a dict of values to send via serial to a tuple values in correct sequence. Many values must ultimately be sent to struct.pack in exactly the right sequence, but it's difficult to work with and debug a large list of values where the context depends on its position. Instead, we can use a dict, where the keys are the strings in the key tuple. A value tuple is formed comprised of the values of the input dictionary in the same sequence as the keys. Additionally, any values that are defined as strings as specified by the corresponding element in the format_tuple are truncated if necessary. Args: d: dictionary to transform into a value tuple. key_tuple: tuple of keys that matches d.keys(). format_tuple: tuple of format specifiers (consistent with struct.pack formats) for the key_tuple. Raises: ValueError: Raised if the key_tuple elements and d.keys() do not match exactly. Returns: Tuple of values of same length as key_tuple and format_tuple. """ if set(key_tuple) != set(d.keys()): raise ValueError('Non matching sets of keys in d (%s) and key_tuple (%s)' % ( sorted(list(d.keys())), sorted(list(key_tuple)))) values = [] for n, k in enumerate(key_tuple): if format_tuple[n].endswith('s'): value = d[k] # 9s, for example, means 9 bytes will be sent; since a string must end with a # terminating character, the max length string that can be fit into a 9s field # is 8 characters. max_length = int(format_tuple[n][:-1]) - 1 if len(value) > max_length: Log('At most an %d-length string is expected, yet d[%s] = %s, %d characters;' ' string truncated to fit' % (max_length, k, value, len(value))) values.append(d[k][:max_length]) else: values.append(d[k]) return tuple(values) def GetNow(json_desc_dict, additional_attr): """Identifies the epoch to use in the Arduinos for the "current" time, i.e.: now. Simulations should use a timestamp contemporaneous with the flights, whereas live data should use the current timestamp. """ if not additional_attr: return 0 if additional_attr['simulation']: return json_desc_dict['now'] return time.time() def GenerateRemoteMessage(flight, json_desc_dict, settings, additional_attr, display_mode): """Generates a value-tuple to be packed and sent to the arduino remote. Args: flight: dictionary describing the most recent flight. json_desc_dict: dictionary representing the current radio signal. settings: dictionary representing the current state of the messageboard configuration. additional_attr: dictionary with miscellaneous attributes from messageboard. display_mode: integer specifying what display mode, so that the text display lines can be appropriately configured. Returns: Dictionary of values, where the dict keys and types are specified by RemoteMain.write_config. """ now = flight.get('now') # time flight was seen simulated_time = json_desc_dict['simulated_time'] # simulated or real time line1_decimal_mask = '00000000' line2_decimal_mask = '00000000' if display_mode == DISP_LAST_FLIGHT_NUMB_ORIG_DEST: # UAL1827 / SFO-LAX line1 = '' line2 = '' if flight: line1 = messageboard.DisplayFlightNumber(flight) origin = messageboard.DisplayOriginIata(flight)[:3] destination = messageboard.DisplayDestinationIata(flight)[:3] line2 = '%s-%s' % (origin, destination) elif display_mode == DISP_LAST_FLIGHT_AZIMUTH_ELEVATION: # AZM+193.1 / ALT +79.3 current_angles = AzimuthAltitude(flight, GetNow(json_desc_dict, additional_attr)) line1 = '' line2 = '' if flight: if current_angles: (azimuth, altitude) = current_angles line1 = 'AZM%s' % FloatToAlphanumericStr(azimuth, 1, 5, True) line1_decimal_mask = '00000010' line2 = 'ALT%s' % FloatToAlphanumericStr(altitude, 1, 5, True) line2_decimal_mask = '00000010' else: line1 = KEY_NOT_PRESENT_STRING line2 = KEY_NOT_PRESENT_STRING elif display_mode == DISP_FLIGHT_COUNT_LAST_SEEN: # 18 TODAY / T+ 14H line1 = '%2d TODAY' % json_desc_dict.get('flight_count_today', 0) elapsed_time_str = 'UNK' if now: elapsed_time_str = SecondsToShortString(simulated_time - now) line2 = 'T+ %s' % elapsed_time_str elif display_mode == DISP_RADIO_RANGE: # RNG 87MI / 9 PLANES radio_range = json_desc_dict.get('radio_range_miles') radio_range_str = KEY_NOT_PRESENT_STRING if radio_range is not None: radio_range_str = '%2dMI' % round(radio_range) line1 = 'RNG %s' % radio_range_str radio_range_flights = json_desc_dict.get('radio_range_flights', 0) plural = '' if radio_range_flights != 1: plural = 'S' line2 = '%d PLANE%s' % (radio_range_flights, plural) d = {} setting_screen_enabled = False if 'setting_screen_enabled' in settings: setting_screen_enabled = True d['setting_screen_enabled'] = setting_screen_enabled d['setting_max_distance'] = settings['setting_max_distance'] d['setting_max_altitude'] = settings['setting_max_altitude'] d['setting_on_time'] = settings['setting_on_time'] d['setting_off_time'] = settings['setting_off_time'] d['setting_delay'] = settings['setting_delay'] d['line1'] = line1 d['line2'] = line2 d['line1_dec_mask'] = int(line1_decimal_mask, 2) d['line2_dec_mask'] = int(line2_decimal_mask, 2) return d def ExecuteArduinoCommand(command, settings, display_mode, low_battery, to_parent_q, link): """Executes the request as communicated in the command string. The remote may make one of the following requests: - Update a setting - (Re)display a recent flight - Display a histogram - Send information for a different display mode - Indicate that the battery is low Args: command: dictionary representing all data fields from remote. settings: dictionary representing the current state of the messageboard configuration. display_mode: current display mode; only passed so that we may identify changes. low_battery: current battery status; only passed so that we may identify changes. to_parent_q: multiprocessing queue, where instructions to send back to messageboard, if any, can be placed. link: the open serial link. Returns: A 3-tuple of potentially-updated display_mode, low_battery, the updated settings dictionary. """ # command might update a setting; see if there's a change, and if so, write to disk setting_change = False # remote sees T/F whereas messageboard.py & the web interface expect 'on'/absent key if command['setting_screen_enabled_bool']: command['setting_screen_enabled'] = 'on' log_lines = [] setting_keys = ['setting_screen_enabled', 'setting_max_distance', 'setting_max_altitude', 'setting_on_time', 'setting_off_time', 'setting_delay'] for key in setting_keys: if command.get(key) != settings.get(key): log_lines.append(' |-->Setting %s updated from %s to %s' % ( key, str(settings[key]), str(command[key]))) setting_change = True settings[key] = command[key] if setting_change: settings_string = messageboard.BuildSettings(settings) to_parent_q.put(('update_settings', (settings_string, ))) # a command might request info about flight to be (re)displayed, irrespective of # whether the screen is on; if so, let's put that message at the front of the message # queue, and delete any subsequent messages in queue because presumably the button # was pushed either a) when the screen was off (so no messages in queue), or b) # because the screen was on, but the last flight details got lost after other screens! if command['last_plane']: to_parent_q.put(('replay', ())) log_lines.append(' |-->Requested last flight (re)display') # a command might request a histogram; simply generate and save a histogram file to disk if command['histogram_enabled']: h_type = GetName(HISTOGRAM_TYPES, command['current_hist_type']) h_history = GetName(HISTOGRAM_HISTORY, command['current_hist_history']) to_parent_q.put(('histogram', (h_type, h_history))) log_lines.append(' |-->Requested %s histogram with %s data' % (h_type, h_history)) # a command might update us on the display mode; based on the display mode, we might # pass different attributes back to the remote if display_mode != command['display_mode']: log_lines.append(' |-->Display mode set to %d (%s)' % ( command['display_mode'], DISPLAY_MODE_NAMES[display_mode])) # a command might tell us the battery is low to_parent_q.put( ('pin', (messageboard.GPIO_ERROR_BATTERY_CHARGE, command['low_battery']))) if low_battery != command['low_battery']: log_lines.append(' |-->Low battery set to %d' % command['low_battery']) if VERBOSE: if log_lines: log_lines.insert(0, '') # for improved formatting Log('\n'.join(log_lines), link) return command['display_mode'], command['low_battery'], settings, setting_change def SecondsToShortString(seconds): """Converts a number of seconds to a three-character time representation (i.e.: 87M). Converts seconds to a three character representation containing at most two digits (1-99) and one character indicating time unit (S, M, H, or D). Args: seconds: Number of seconds. Returns: String as described. """ s = round(seconds) if s < 99: return '%2dS' % s m = round(seconds / messageboard.SECONDS_IN_MINUTE) if m < 99: <----SKIPPED LINES----> return '' # which command should we pick? if randomized: index = random.randrange(len(potential_commands)) else: command_number = int(counter * fraction_command) - 1 index = command_number % len(potential_commands) return potential_commands[index] def ReplaceWithNA(s, function=None): """Replaces a messageboard not-known value with the alphanumeric not-known value.""" if s in (messageboard.KEY_NOT_PRESENT_STRING, 'None') or s is None: return KEY_NOT_PRESENT_STRING if function: return function(s) return s def GetId(mapping, name): """From a mapping tuple of 2-tuples of form (number, name), converts name into number.""" for t in mapping: if t[1] == name: return t[0] raise ValueError('%s is not in %s' % (name, mapping)) def GetName(mapping, n): """From a mapping tuple of 2-tuples of form (number, name), converts number into name.""" for t in mapping: if t[0] == n: return t[1] raise ValueError('%d is not in %s' % (n, mapping)) def SplitFormat(config): """From a tuple of two-tuples, returns two tuples, where each has respective elements.""" k = tuple([t[0] for t in config]) f = tuple([t[1] for t in config]) return k, f def RemoteMain(to_arduino_q, to_parent_q, shutdown): """Main process for controlling the arduino-based remote control. Takes various data from the messageboard and formats it for display on the alphanumeric display on the remote control; provides latest settings; and executes any commands such as histogram requests or setting updates. """ Log('Process started with process id %d' % os.getpid()) # Ensures that the child can exit if the parent exits unexpectedly # docs.python.org/2/library/multiprocessing.html#multiprocessing.Queue.cancel_join_thread to_arduino_q.cancel_join_thread() to_parent_q.cancel_join_thread() #pylint: disable = bad-whitespace read_config = ( # when confirmed is true, this is a command; when false, this is a heartbeat ('confirmed', '?'), ('setting_screen_enabled_bool', '?'), ('setting_max_distance', 'h'), ('setting_max_altitude', 'h'), ('setting_on_time', 'h'), ('setting_off_time', 'h'), ('setting_delay', 'h'), ('last_plane', '?'), ('display_mode', 'h'), ('histogram_enabled', '?'), ('current_hist_type', 'h'), ('current_hist_history', 'h'), ('low_battery', '?')) write_config = ( ('setting_screen_enabled', '?'), ('setting_max_distance', 'h'), ('setting_max_altitude', 'h'), ('setting_on_time', 'h'), ('setting_off_time', 'h'), ('setting_delay', 'h'), ('line1', '8s'), ('line2', '8s'), ('line1_dec_mask', 'h'), ('line2_dec_mask', 'h')) #pylint: enable = bad-whitespace read_keys, read_format = SplitFormat(read_config) write_keys, write_format = SplitFormat(write_config) values_d = {} low_batt = False to_parent_q.put(('pin', (messageboard.GPIO_ERROR_BATTERY_CHARGE, low_batt))) link = Serial( *REMOTE_CONNECTION, read_timeout=5, error_pin=messageboard.GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, to_parent_q=to_parent_q, read_format=read_format, write_format=write_format, name='Remote') link.Open() display_mode = 0 flight, json_desc_dict, settings, additional_attr = InitialMessageValues(to_arduino_q) next_read = time.time() + READ_DELAY_TIME next_write = time.time() + WRITE_DELAY_TIME ignore_parent_settings = False updated_settings = settings while not shutdown.value: if not to_arduino_q.empty(): to_arduino_message = to_arduino_q.get(block=False) flight, json_desc_dict, settings, additional_attr = to_arduino_message # There is a tricky parallel process timing issues: the remote has more up-to-date # settings for a brief interval until the parent has chance to write the settings; # all already-queued messages (perhaps at most one) will still have the old settings # prior to any updates from the remote. Thus, we need to use the updated settings # from the remote, ignoring the settings from the parent, until such time as the # parent settings match once again the settings on the remote. if settings == updated_settings: ignore_parent_settings = False else: Log('Settings from main out of date; ignoring') if ignore_parent_settings: settings = updated_settings if time.time() >= next_write: message_dict = GenerateRemoteMessage( flight, json_desc_dict, settings, additional_attr, display_mode) message_tuple = DictToValueTuple(message_dict, write_keys, write_format) link.Write(message_tuple) next_write = time.time() + WRITE_DELAY_TIME if time.time() >= next_read: values_t = link.Read() # simple ack message sent by servos values_d = dict(zip(read_keys, values_t)) if values_d.get('confirmed'): results = ExecuteArduinoCommand( values_d, settings, display_mode, low_batt, to_parent_q, link) display_mode, low_batt, updated_settings, ignore_parent_settings = results next_read = time.time() + READ_DELAY_TIME if not SIMULATE_ARDUINO: time.sleep(READ_DELAY_TIME) link.Close() Log('Shutdown signal received by process %d' % os.getpid(), link) to_parent_q.put(('pin', (messageboard.GPIO_ERROR_ARDUINO_REMOTE_CONNECTION, True))) |