01234567890123456789012345678901234567890123456789012345678901234567890123456789
12345678 9101112131415161718192021 2223 24252627282930313233343536373839 404142434445464748495051525354555657585960616263646566676869 8081828384858687888990919293949596979899 100101102103104105106107108109110111112113114115116117118119120 160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 457458459460461462463464465466467468469470471472473474475476 477478479480 481482483484485486487488489490491492493494495496497498499500501502503 520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 549550551552553554555556557558559560561562563564565566567568 575576577578579580581582583584585586587588589590591592593594 595596597598599600601602603604605606607608609610611612 613614615 616617618619 620621622623624625626627628629630631632633634635636637638 639640641642 643 644645646647648649650651652653654655656657658659660661662663664665666667 721722723724725726727728729730731732733734735736737738739740741 742743744745746747748749750751752753754755756757758 759760761762763764765766767768769770771772773774775776777778 779780781782783784 785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829 830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863 864865866867868869870871872873874875 876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958 101810191020102110221023102410251026102710281029103010311032103310341035103610371038 103910401041 104210431044104510461047104810491050 1051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102 11031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140 1141114211431144114511461147 | #!/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 <----SKIPPED LINES----> (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 <----SKIPPED LINES----> 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' <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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) <----SKIPPED LINES----> 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) <----SKIPPED LINES----> 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 <----SKIPPED LINES----> 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 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))) |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
1234567891011121314151617181920 2122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273 84858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 497498499500501502503504505506507508509510511512513514515516 533534535536537538539540541542543544545546547548549550551552 553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649 650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 679680681682683684685686687688689690691692693694695696697698 752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829 830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996 105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199 120012011202 | #!/usr/bin/python3 import numbers import os import random import struct import subprocess import sys import termios import time import psutil import serial import serial.tools.list_ports from pySerialTransfer import pySerialTransfer import messageboard ARDUINO_LOG = 'arduino_log.txt' ARDUINO_ROLLING_LOG = 'arduino_rolling_log.txt' SERIALS_LOG = 'arduino_serials_log.txt' VERBOSE = False # log additional fine-grained details into ARDUINO_LOG LOG_SERIALS = False # log serial data sent from Arduino to ARDUINO_LOG 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 ARDUINO_ROLLING_LOG = messageboard.WEBSERVER_PATH + ARDUINO_ROLLING_LOG CONNECTION_FLAG_BLUETOOTH = 1 CONNECTION_FLAG_USB = 2 CONNECTION_FLAG_SIMULATED = 3 RASPBERRY_PI = psutil.sys.platform.title() == 'Linux' SN_SERVO = '5583834303435111C1A0' SERVO_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (2, '98:D3:11:FC:42:16', 1)) SN_REMOTE = '75835343130351802272' REMOTE_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (1, '98:D3:91:FD:B3:C9', 1)) 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 <----SKIPPED LINES----> (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' if file == ARDUINO_LOG: rolling = ARDUINO_ROLLING_LOG else: rolling = None messageboard.Log('%s: %s' % (additional_text, message), file=file, rolling=rolling) 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 <----SKIPPED LINES----> 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: # Error turned on when main initiated; turned off when connected 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' <----SKIPPED LINES----> 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 3-tuple of the rfcomm_device number (i.e.: the 1 in /dev/rfcomm1), mac_address of the bluetooth radio, and the bluetooth channel; all 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, channel = 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 %d' % (rfcomm_device, bt_mac_address, channel)) 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 <----SKIPPED LINES----> 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 = [] try: 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]) except (serial.SerialException, termios.error) as e: Log('Error in ReadBytes: %s' % e) read_bytes = [] 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) <----SKIPPED LINES----> 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) # debugging messages to help identify whether servos are getting correct flight path info 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) <----SKIPPED LINES----> 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. """ sys.stderr = open(messageboard.STDERR_FILE, 'a') 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, configuration, 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) flight_present = False while not shutdown.value: if not to_arduino_q.empty(): flight, json_desc_dict, configuration, additional_attr = to_arduino_q.get( block=False) if 'test_servos' in configuration: messageboard.RemoveSetting(configuration, 'test_servos') link.Write((0, 0, *LASER_RGB_OFF)) time.sleep(1) link.Write((90, 0, *LASER_RGB_OFF)) time.sleep(1) link.Write((180, 0, *LASER_RGB_OFF)) time.sleep(1) link.Write((270, 0, *LASER_RGB_OFF)) 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] >= configuration['minimum_altitude_servo_tracking']: flight_present = True laser_rgb = LaserRGBFlight(flight) link.Write((*current_angles, *laser_rgb)) last_angles = current_angles # flight no longer tracked; send one more command to turn off lasers elif flight_present: link.Write((*last_angles, *LASER_RGB_OFF)) flight_present = False next_write = time.time() + WRITE_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 <----SKIPPED LINES----> 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; additionally strings are truncated if necessary and converted from ascii to bytes. """ 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))) truncated_string = d[k][:max_length] string_bytes = bytes(truncated_string, 'ascii') values.append(string_bytes) 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, configuration, 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. configuration: 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. """ flight_last_seen = flight.get('now') # time flight was seen 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 flight_last_seen: elapsed_time_str = SecondsToShortString( GetNow(json_desc_dict, additional_attr) - flight_last_seen) 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 configuration: setting_screen_enabled = True d['setting_screen_enabled'] = setting_screen_enabled d['setting_max_distance'] = configuration['setting_max_distance'] d['setting_max_altitude'] = configuration['setting_max_altitude'] d['setting_on_time'] = configuration['setting_on_time'] d['setting_off_time'] = configuration['setting_off_time'] d['setting_delay'] = configuration['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, configuration, 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. configuration: 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 configuration 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) != configuration.get(key): log_lines.append(' |-->Setting %s updated from %s to %s' % ( key, str(configuration[key]), str(command[key]))) setting_change = True configuration[key] = command[key] if setting_change: settings_string = messageboard.BuildSettings(configuration) to_parent_q.put(('update_configuration', (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'], configuration, 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 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 describing the format, returns keys and format. Transforms a easy-to-read format specifier of, for example: (('key1', '?'), ('key2', '?'), ('key3', 'h')) into a tuple of the keys and formats in the same sequence as provided: ('key1', 'key2', 'key3') ('?', '?', 'h') and a string format specifier: '??h' """ k_tuple = tuple([t[0] for t in config]) f_tuple = tuple([t[1] for t in config]) f_string = ''.join(f_tuple) # The initial character of = disables alignment padding # i.e.: https://docs.python.org/3/library/struct.html#struct-alignment f_string = '=' + f_string return k_tuple, f_tuple, f_string 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 configuration; and executes any commands such as histogram requests or setting updates. """ sys.stderr = open(messageboard.STDERR_FILE, 'a') 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', 'l'), ('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', 'l'), ('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, unused_read_format_tuple, read_format_string = SplitFormat(read_config) write_keys, write_format_tuple, write_format_string = 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_string, write_format=write_format_string, name='Remote') link.Open() display_mode = 0 flight, json_desc_dict, configuration, additional_attr = InitialMessageValues( to_arduino_q) next_read = time.time() + READ_DELAY_TIME next_write = time.time() + WRITE_DELAY_TIME ignore_parent_configuration = False updated_configuration = configuration while not shutdown.value: if not to_arduino_q.empty(): to_arduino_message = to_arduino_q.get(block=False) flight, json_desc_dict, configuration, additional_attr = to_arduino_message # There is a tricky parallel process timing issues: the remote has more up-to-date # configuration for a brief interval until the parent has chance to write the # configuration; all already-queued messages (perhaps at most one) will still have # the old configuration prior to any updates from the remote. Thus, we need to # use the updated configuration from the remote, ignoring the configuration from # parent until such time as they match. if configuration == updated_configuration: ignore_parent_configuration = False else: Log('REMOTE: Settings from main out of date; ignoring') if ignore_parent_configuration: configuration = updated_configuration if time.time() >= next_write: message_dict = GenerateRemoteMessage( flight, json_desc_dict, configuration, additional_attr, display_mode) message_tuple = DictToValueTuple(message_dict, write_keys, write_format_tuple) 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, configuration, display_mode, low_batt, to_parent_q, link) (display_mode, low_batt, updated_configuration, ignore_parent_configuration) = results next_read = time.time() + 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))) |