01234567890123456789012345678901234567890123456789012345678901234567890123456789
1234567891011121314151617 1819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667 686970717273747576777879808182838485868788 979899100101102103104105106107108109110111112113114115116 117118119120121122123124125126 127128129 130 131132133134135136137138139140141142143144145146147148149 150151152153154155156157158159160161162163 164165166167168 169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 223224225226227228229230231 232233234235236237238239240241242243244 245246247248249250251252253254255256257258259260261262263264265266267268269270271272273 274275276277278279280281282283284285286 287288289290291292293294295296297298299300301302303304305306 307308309310 311312313314315316317318319320321322323324325 326327328 329330331 332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 388389390 391392393394395396397398399400401402403404405 406407408409410411412413414415416417418419420421422423424425426427 428429430431432433434 435436437438 439440441442443444445446447448449450451452 453 454455456457458459460461 462463464465466467468469470471472473474475476477478479480481 501502503504505506507508509510511512513514515516517518519520521522523524 525526527528529530531532533534535536537538539540541542543544545 546547548549550551552553554555 556557558559560561562563564565566567568569570571572 573574575576577 578 579580 581582583584 585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651 652653654655656657658659 660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710 736737738739740741742743744745746747748749750751752753754755756757 758759760761762763764765 766767768769770771772773774775776777778779780781782783 784785786787788789790791792793794795796797798 799800801802 803804 805806807808809810811812813814815816817 818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874 875876877878879880881882883884885886887888889890891892893894 926927928929930931932933934935936937938939940941942943944945946947948 949 950951952953954955956957958959960961962963964965966967968969970971972973974975 976977978979980981982983984985986987988989990 991992993 9949959969979989991000100110021003 10041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029 10301031103210331034103510361037103810391040104110421043104410451046104710481049 10531054105510561057105810591060106110621063106410651066106710681069107010711072 10731074107510761077107810791080 10811082 1083 1084108510861087108810891090 1091109210931094109510961097109810991100110111021103 1104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185 1186118711881189119011911192 119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228 1229 12301231123212331234123512361237 1238 12391240124112421243124412451246124712481249125012511252125312541255 125612571258125912601261126212631264126512661267126812691270127112721273127412751276 | #!/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 from constants import RASPBERRY_PI, MESSAGEBOARD_PATH, WEBSERVER_PATH, SHUTDOWN_TEXT 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 REMOTE_DISPLAY_MODE = 'display_mode.txt' SERVO_SIMULATED_IN = 'servo_in.txt' SERVO_SIMULATED_OUT = 'servo_out.txt' REMOTE_SIMULATED_IN = 'remote_in.txt' REMOTE_SIMULATED_OUT = 'remote_out.txt' if RASPBERRY_PI: ARDUINO_LOG = MESSAGEBOARD_PATH + ARDUINO_LOG SERIALS_LOG = MESSAGEBOARD_PATH + SERIALS_LOG SERVO_SIMULATED_OUT = MESSAGEBOARD_PATH + SERVO_SIMULATED_OUT SERVO_SIMULATED_IN = MESSAGEBOARD_PATH + SERVO_SIMULATED_IN REMOTE_SIMULATED_OUT = MESSAGEBOARD_PATH + REMOTE_SIMULATED_OUT REMOTE_SIMULATED_IN = MESSAGEBOARD_PATH + REMOTE_SIMULATED_IN REMOTE_DISPLAY_MODE = MESSAGEBOARD_PATH + REMOTE_DISPLAY_MODE ARDUINO_ROLLING_LOG = 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)) # with MOSFET #SERVO_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (3, '98:D3:91:FD:B1:8F', 1)) # no MOSFET SN_REMOTE = '75835343130351802272' # directly connected to Serial2 # connected thru MOSFET to Serial1 REMOTE_CONNECTION = (CONNECTION_FLAG_BLUETOOTH, (1, '98:D3:91:FD:B3:C9', 1)) LASER_OFF = (False, False, False) LASER_ALL = (True, True, True) LASER_RED = (True, False, False) LASER_GREEN = (False, True, False) LASER_BLUE = (False, False, True) 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'), <----SKIPPED LINES----> 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 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 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: # Error turned on when main initiated; turned off when connected error_message = 'Process %s (%s) initialized into error state' % ( os.getpid(), str(self.name)) Log(error_message) self.to_parent_q.put(('pin', (self.error_pin, True, error_message))) self.start_time = time.time() def __str__(self): return self.name 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, name=self.name, 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, name=self.name, 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, close_message): """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, close_message))) Log(close_message, self) 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, bytes_read=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 bytes_read: if passed, the bytes that are read are appended to the list. 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, bytes_read=bytes_read) except OSError as e: failure_message = 'Failed to read from %s: %s' % (self.name, e) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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: failure_message = 'Heartbeat not received in %.2f seconds (expected: %.2f) on %s' % ( self.last_read - self.last_receipt, self.read_timeout, self.name) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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: failure_message = 'Failed to write: %s' % e if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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, name=None): """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. name: string name of the connection to display in error logging. 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 if not name: name = '%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' % (name, b)) return link Log('No handshake received at %s after %d seconds on attempt %d' % (name, timeout, attempt)) except OSError as e: Log('Handshake error with %s on attempt %d: %s' % (name, 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 break link = None 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( <----SKIPPED LINES----> """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, bytes_read=None): """Read and unpacks the bytes in one go.""" read_bytes = ReadBytes(link) if isinstance(bytes_read, list): bytes_read.extend(read_bytes) 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) # 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) 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 ServoTestOrdinal(link): """Point laser at each of 0, 90, 180, 270 and hold for a second with different colors.""" link.Write((0, 0, *LASER_ALL)) time.sleep(1) link.Write((90, 0, *LASER_RED)) time.sleep(1) link.Write((180, 0, *LASER_GREEN)) time.sleep(1) link.Write((270, 0, *LASER_BLUE)) time.sleep(1) def ServoTestSweep(link, altitude=45): """Sweep red laser around 360 degrees.""" for azimuth in range(0, 360, 10): link.Write((azimuth, altitude, *LASER_RED)) time.sleep(WRITE_DELAY_TIME) 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=60, error_pin=messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION, to_parent_q=to_parent_q, read_format='l', write_format='ff???', name='Servo') link.Open() last_flight = {} last_angles = (0, 0) flight, json_desc_dict, configuration, additional_attr = InitialMessageValues( to_arduino_q) next_read = 0 next_write = 0 now = GetNow(json_desc_dict, additional_attr) 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_ordinal' in configuration: messageboard.RemoveSetting(configuration, 'test_servos_ordinal') ServoTestOrdinal(link) elif 'test_servos_sweep' in configuration: messageboard.RemoveSetting(configuration, 'test_servos_sweep') ServoTestSweep(link) new_flight = DifferentFlights(flight, last_flight) if new_flight: Log('Flight changed from %s to %s' % ( messageboard.DisplayFlightNumber(last_flight), 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_OFF)) last_flight = flight if time.time() >= next_read: heartbeat = link.Read() # simple ack message sent by servos next_read = time.time() + READ_DELAY_TIME if heartbeat and VERBOSE: Log(heartbeat) 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']: if VERBOSE: Log('Flight #: %s current_angles: %s' % ( messageboard.DisplayFlightNumber(flight), str(current_angles))) laser_rgb = LaserRGBFlight(flight) link.Write((*current_angles, *laser_rgb)) <----SKIPPED LINES----> return LASER_BLUE def DifferentFlights(f1, f2): """True if both squawk and flight number different; false otherwise.""" if f1 is None and f2 is None: return True if f1 is None or f2 is None: return True if ( f1.get('flight_number') != f2.get('flight_number') and f1.get('squawk') != f2.get('squawk')): return True return False 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; 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 the 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, 6, True) line1_decimal_mask = '00000010' line2 = 'ALT%s' % FloatToAlphanumericStr(altitude, 1, 6, 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 flight_count_today = additional_attr.get('flight_count_today', 0) flight_count_today = ('%2d' % flight_count_today).ljust(3) line1 = '%sTODAY' % flight_count_today elapsed_time_str = 'UNK' if flight_last_seen: <----SKIPPED LINES----> d['line1_dec_mask'] = int(line1_decimal_mask, 2) d['line2_dec_mask'] = int(line2_decimal_mask, 2) d['display_mode'] = display_mode 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 2-tuple of potentially-updated display_mode, and low_battery. """ # command might update a setting; see if there's a change, and if so, write to disk setting_change = False log_lines = [] # makes a copy so as to not modify underlying config; we don't want to modify underlying # because otherwise the settings will bounce around (values read from disk -> # -> new values set by arduino -> old values from disk -> new values from disk after # Arduino update). configuration = dict(configuration) setting_keys = ['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.get(key)), str(command[key]))) setting_change = True configuration[key] = command[key] remote_key = 'setting_screen_enabled_bool' system_key = 'setting_screen_enabled' # remote sees T/F whereas messageboard.py & the web interface expect 'on'/absent key if command[remote_key] and system_key not in configuration: setting_change = True configuration[system_key] = 'on' log_lines.append(' |-->Setting %s updated from None to on' % system_key) elif not command[remote_key] and system_key in configuration: setting_change = True configuration.pop(system_key) log_lines.append(' |-->Setting %s updated from on to None' % system_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])) messageboard.WriteFile(REMOTE_DISPLAY_MODE, str(command['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 log_lines: log_lines.insert(0, '') # for improved formatting Log('\n'.join(log_lines), link) return command['display_mode'], command['low_battery'] def SecondsToShortString(s): """Converts a number of seconds to a three-character time representation (i.e.: 23M). Converts seconds to a three character representation containing at most two digits, potentially with a decimal point, and one character indicating time unit (S, M, H, or D). Args: s: Number of seconds. Returns: 2-tuple of string as described and string map indicating decimal position """ m = s / messageboard.SECONDS_IN_MINUTE h = s / messageboard.SECONDS_IN_HOUR d = s / messageboard.SECONDS_IN_DAY no_decimals = '000' decimal_after_first_character = '100' partial_decimal_mask = no_decimals if round(s, 1) < 10: numeric_string = '%sS' % FloatToAlphanumericStr(s, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif s < messageboard.SECONDS_IN_MINUTE: <----SKIPPED LINES----> numeric_string = '%sM' % FloatToAlphanumericStr(m, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif m < messageboard.MINUTES_IN_HOUR: numeric_string = '%2dM' % round(m) elif round(h, 1) < 10: numeric_string = '%sH' % FloatToAlphanumericStr(h, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif h < messageboard.HOURS_IN_DAY: numeric_string = '%2dH' % round(h) elif round(d, 1) < 10: numeric_string = '%sD' % FloatToAlphanumericStr(d, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character else: numeric_string = '%2dD' % round(d) return numeric_string, partial_decimal_mask def SimulateCommand(potential_commands, counter, fraction_command=0.01, randomized=False): """Simulates the remote generating a command for remote-free testing. A command from the list of potential_commands is generated periodically, roughly fraction_command percent of the time. - not randomized: a command is returned every time (counter / fraction_command) rolls over to a new integer. The command returned is the next one in the list of potential commands. For instance, if fraction_command = 0.01, when counter=100, the first command in potential_commands is returned; when counter=200, the second command is returned, and so on. At the end of the list, we rotate back to the first command. - randomized: fraction_command percent of the time, a randomly selected command is sent. Args: potential_commands: A fully-formed string (potentially including the ack key-value pair) to sent to the remote. counter: integer indicating how many times a command could potentially have been generated; only relevant if randomized is False. fraction_command: The fraction (or percent) of the time a command is returned. randomized: Boolean indicating whether the command generation is deterministic or randomized. Returns: Potentially empty string representing a fully formed command that might come from an remote, had one been connected. """ # should we generate a command? generate = False if randomized: generate = random.random() < fraction_command else: generate = int(fraction_command * counter) != int(fraction_command * (counter - 1)) if not generate: 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 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) # https://docs.python.org/3/library/struct.html#struct-alignment f_string = '<' + f_string return k_tuple, f_tuple, f_string def SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, display_mode, write_keys, write_format_tuple, link): """Sends a message to the remote with current settings & text for given mode.""" 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 return next_write 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', '?'), # 1 bytes ('setting_max_distance', 'H'), # 2 bytes ('setting_max_altitude', 'L'), # 4 bytes ('setting_on_time', 'H'), # 2 bytes ('setting_off_time', 'H'), # 2 bytes ('setting_delay', 'H'), # 2 bytes ('line1', '9s'), # 9 bytes; 8 character plus terminator ('line2', '9s'), # 9 bytes; 8 character plus terminator ('line1_dec_mask', 'H'), # 2 bytes ('line2_dec_mask', 'H'), # 2 bytes ('display_mode', 'H'), # 2 bytes ('last_flight_available', '?'), # 1 byte ) #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=60, 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() # Read in the saved display mode, if it exists display_mode = messageboard.ReadFile(REMOTE_DISPLAY_MODE, log_exception=False) if not display_mode: display_mode = DISP_LAST_FLIGHT_NUMB_ORIG_DEST else: display_mode = int(display_mode) flight, json_desc_dict, configuration, additional_attr = InitialMessageValues( to_arduino_q) next_read = 0 next_write = 0 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 if 'test_remote' in configuration: messageboard.RemoveSetting(configuration, 'test_remote') def TestDisplayMode(m): SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, m, write_keys, write_format_tuple, link) time.sleep(1) TestDisplayMode(DISP_LAST_FLIGHT_NUMB_ORIG_DEST) TestDisplayMode(DISP_LAST_FLIGHT_AZIMUTH_ELEVATION) TestDisplayMode(DISP_FLIGHT_COUNT_LAST_SEEN) TestDisplayMode(DISP_RADIO_RANGE) if time.time() >= next_write: next_write = SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, display_mode, write_keys, write_format_tuple, link) <----SKIPPED LINES----> |
01234567890123456789012345678901234567890123456789012345678901234567890123456789
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354 5556 57585960616263646566676869707172737475767778798081828384858687 96979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747 773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939 971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101 110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341 | #!/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 from constants import ( RASPBERRY_PI, MESSAGEBOARD_PATH, WEBSERVER_PATH, SHUTDOWN_TEXT) 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 REMOTE_DISPLAY_MODE = 'display_mode.txt' SERVO_SIMULATED_IN = 'servo_in.txt' SERVO_SIMULATED_OUT = 'servo_out.txt' REMOTE_SIMULATED_IN = 'remote_in.txt' REMOTE_SIMULATED_OUT = 'remote_out.txt' if RASPBERRY_PI: ARDUINO_LOG = MESSAGEBOARD_PATH + ARDUINO_LOG SERIALS_LOG = MESSAGEBOARD_PATH + SERIALS_LOG SERVO_SIMULATED_OUT = MESSAGEBOARD_PATH + SERVO_SIMULATED_OUT SERVO_SIMULATED_IN = MESSAGEBOARD_PATH + SERVO_SIMULATED_IN REMOTE_SIMULATED_OUT = MESSAGEBOARD_PATH + REMOTE_SIMULATED_OUT REMOTE_SIMULATED_IN = MESSAGEBOARD_PATH + REMOTE_SIMULATED_IN REMOTE_DISPLAY_MODE = MESSAGEBOARD_PATH + REMOTE_DISPLAY_MODE ARDUINO_ROLLING_LOG = 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)) LASER_OFF = (False, False, False) LASER_ALL = (True, True, True) LASER_RED = (True, False, False) LASER_GREEN = (False, True, False) LASER_BLUE = (False, False, True) 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'), <----SKIPPED LINES----> 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 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 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: # Error on when main initiated; off when connected error_message = 'Process %s (%s) initialized into error state' % ( os.getpid(), str(self.name)) Log(error_message) self.to_parent_q.put(('pin', (self.error_pin, True, error_message))) self.start_time = time.time() def __str__(self): return self.name 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, name=self.name, 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, name=self.name, 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, close_message): """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, close_message))) Log(close_message, self) 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, bytes_read=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 bytes_read: if passed, the bytes that are read are appended to the list. Returns: Tuple of values matching that as identified in format_string. """ if self.connection_type == CONNECTION_FLAG_SIMULATED: if ( self.__simulated_reads__ and # time for next time.time() - self.start_time > self.__simulated_reads__[0][0]): 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, bytes_read=bytes_read) except OSError as e: failure_message = 'Failed to read from %s: %s' % (self.name, e) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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): failure_message = ( 'Heartbeat not received in %.2f seconds (expected: %.2f) on %s' % ( self.last_read - self.last_receipt, self.read_timeout, self.name)) if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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: failure_message = 'Failed to write: %s' % e if self.error_pin: self.to_parent_q.put(('pin', (self.error_pin, True, failure_message))) self.Reopen(log_message=failure_message) 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 once whether serial 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 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, name=None): """Attempts to open bluetooth, 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. name: string name of the connection to display in error logging. 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 if not name: name = '%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 if b: Log('Handshake received at %s by receipt of bytes %s' % (name, b)) return link Log('No handshake received at %s after %d seconds on attempt %d' % (name, timeout, attempt)) except OSError as e: Log('Handshake error with %s on attempt %d: %s' % (name, 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 break link = None 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( <----SKIPPED LINES----> """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 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, bytes_read=None): """Read and unpacks the bytes in one go.""" read_bytes = ReadBytes(link) if isinstance(bytes_read, list): bytes_read.extend(read_bytes) 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) # 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) 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 ServoTestOrdinal(link): """Point laser at each of 0, 90, 180, 270 and hold with different colors.""" link.Write((0, 0, *LASER_ALL)) time.sleep(1) link.Write((90, 0, *LASER_RED)) time.sleep(1) link.Write((180, 0, *LASER_GREEN)) time.sleep(1) link.Write((270, 0, *LASER_BLUE)) time.sleep(1) def ServoTestSweep(link, altitude=45): """Sweep red laser around 360 degrees.""" for azimuth in range(0, 360, 10): link.Write((azimuth, altitude, *LASER_RED)) time.sleep(WRITE_DELAY_TIME) 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=60, error_pin=messageboard.GPIO_ERROR_ARDUINO_SERVO_CONNECTION, to_parent_q=to_parent_q, read_format='l', write_format='ff???', name='Servo') link.Open() last_flight = {} last_angles = (0, 0) flight, json_desc_dict, configuration, additional_attr = InitialMessageValues( to_arduino_q) next_read = 0 next_write = 0 now = GetNow(json_desc_dict, additional_attr) 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_ordinal' in configuration: messageboard.RemoveSetting(configuration, 'test_servos_ordinal') ServoTestOrdinal(link) elif 'test_servos_sweep' in configuration: messageboard.RemoveSetting(configuration, 'test_servos_sweep') ServoTestSweep(link) new_flight = DifferentFlights(flight, last_flight) if new_flight: Log('Flight changed from %s to %s' % ( messageboard.DisplayFlightNumber(last_flight), messageboard.DisplayFlightNumber(flight) ), ser=link) # Turn off laser so line isn't traced while it moves to new position link.Write((*last_angles, *LASER_OFF)) last_flight = flight if time.time() >= next_read: heartbeat = link.Read() # simple ack message sent by servos next_read = time.time() + READ_DELAY_TIME if heartbeat and VERBOSE: Log(heartbeat) 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']: if VERBOSE: Log('Flight #: %s current_angles: %s' % ( messageboard.DisplayFlightNumber(flight), str(current_angles))) laser_rgb = LaserRGBFlight(flight) link.Write((*current_angles, *laser_rgb)) <----SKIPPED LINES----> return LASER_BLUE def DifferentFlights(f1, f2): """True if both squawk and flight number different; false otherwise.""" if f1 is None and f2 is None: return True if f1 is None or f2 is None: return True if ( f1.get('flight_number') != f2.get('flight_number') and f1.get('squawk') != f2.get('squawk')): return True return False 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 dict of values to 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; 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 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 the 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, 6, True) line1_decimal_mask = '00000010' line2 = 'ALT%s' % FloatToAlphanumericStr(altitude, 1, 6, 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 flight_count_today = additional_attr.get('flight_count_today', 0) flight_count_today = ('%2d' % flight_count_today).ljust(3) line1 = '%sTODAY' % flight_count_today elapsed_time_str = 'UNK' if flight_last_seen: <----SKIPPED LINES----> d['line1_dec_mask'] = int(line1_decimal_mask, 2) d['line2_dec_mask'] = int(line2_decimal_mask, 2) d['display_mode'] = display_mode 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 2-tuple of potentially-updated display_mode, and low_battery. """ # command might update a setting; see if there's a change, and write to disk setting_change = False log_lines = [] # makes a copy so as to not modify underlying config; we don't want # to modify underlying because otherwise the settings will bounce # around (values read from disk -> new values set by arduino -> old # values from disk -> new values from disk after Arduino update). configuration = dict(configuration) setting_keys = ['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.get(key)), str(command[key]))) setting_change = True configuration[key] = command[key] remote_key = 'setting_screen_enabled_bool' system_key = 'setting_screen_enabled' # remote sees T/F whereas messageboard.py & the web interface expect # 'on'/absent key if command[remote_key] and system_key not in configuration: setting_change = True configuration[system_key] = 'on' log_lines.append(' |-->Setting %s updated from None to on' % system_key) elif not command[remote_key] and system_key in configuration: setting_change = True configuration.pop(system_key) log_lines.append(' |-->Setting %s updated from on to None' % system_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') # command might request a histogram 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])) messageboard.WriteFile(REMOTE_DISPLAY_MODE, str(command['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 log_lines: log_lines.insert(0, '') # for improved formatting Log('\n'.join(log_lines), link) return command['display_mode'], command['low_battery'] def SecondsToShortString(s): """Converts a number of seconds to a 3-char time representation (i.e.: 23M). Converts seconds to a three character representation containing at most two digits, potentially with a decimal point, and one character indicating time unit (S, M, H, or D). Args: s: Number of seconds. Returns: 2-tuple of string as described and string map indicating decimal position """ m = s / messageboard.SECONDS_IN_MINUTE h = s / messageboard.SECONDS_IN_HOUR d = s / messageboard.SECONDS_IN_DAY no_decimals = '000' decimal_after_first_character = '100' partial_decimal_mask = no_decimals if round(s, 1) < 10: numeric_string = '%sS' % FloatToAlphanumericStr(s, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif s < messageboard.SECONDS_IN_MINUTE: <----SKIPPED LINES----> numeric_string = '%sM' % FloatToAlphanumericStr(m, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif m < messageboard.MINUTES_IN_HOUR: numeric_string = '%2dM' % round(m) elif round(h, 1) < 10: numeric_string = '%sH' % FloatToAlphanumericStr(h, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character elif h < messageboard.HOURS_IN_DAY: numeric_string = '%2dH' % round(h) elif round(d, 1) < 10: numeric_string = '%sD' % FloatToAlphanumericStr(d, 1, 3, sign=False) partial_decimal_mask = decimal_after_first_character else: numeric_string = '%2dD' % round(d) return numeric_string, partial_decimal_mask def SimulateCommand( potential_commands, counter, fraction_command=0.01, randomized=False): """Simulates the remote generating a command for remote-free testing. A command from the list of potential_commands is generated periodically, roughly fraction_command percent of the time. - not randomized: a command is returned every time (counter / fraction_command) rolls over to a new integer. The command returned is the next one in the list of potential commands. For instance, if fraction_command = 0.01, when counter=100, the first command in potential_commands is returned; when counter=200, the second command is returned, and so on. At the end of the list, we rotate back to the first command. - randomized: fraction_command percent of the time, a randomly selected command is sent. Args: potential_commands: A fully-formed string (potentially including the ack key-value pair) to sent to the remote. counter: integer indicating how many times a command could potentially have been generated; only relevant if randomized is False. fraction_command: The fraction (or percent) of the time a command is returned. randomized: Boolean indicating whether the command generation is deterministic or randomized. Returns: Potentially empty string representing a fully formed command that might come from an remote, had one been connected. """ # should we generate a command? generate = False if randomized: generate = random.random() < fraction_command else: generate = int(fraction_command * counter) != int( fraction_command * (counter - 1)) if not generate: 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 with 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 (#, name), converts name into #.""" 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 (#, name), converts # 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) # https://docs.python.org/3/library/struct.html#struct-alignment f_string = '<' + f_string return k_tuple, f_tuple, f_string def SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, display_mode, write_keys, write_format_tuple, link): """Sends message to the remote with current settings & text for given mode.""" 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 return next_write 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 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', '?'), # 1 bytes ('setting_max_distance', 'H'), # 2 bytes ('setting_max_altitude', 'L'), # 4 bytes ('setting_on_time', 'H'), # 2 bytes ('setting_off_time', 'H'), # 2 bytes ('setting_delay', 'H'), # 2 bytes ('line1', '9s'), # 9 bytes; 8 character & terminator ('line2', '9s'), # 9 bytes; 8 character & terminator ('line1_dec_mask', 'H'), # 2 bytes ('line2_dec_mask', 'H'), # 2 bytes ('display_mode', 'H'), # 2 bytes ('last_flight_available', '?'), # 1 byte ) #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=60, 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() # Read in the saved display mode, if it exists display_mode = messageboard.ReadFile(REMOTE_DISPLAY_MODE, log_exception=False) if not display_mode: display_mode = DISP_LAST_FLIGHT_NUMB_ORIG_DEST else: display_mode = int(display_mode) flight, json_desc_dict, configuration, additional_attr = InitialMessageValues( to_arduino_q) next_read = 0 next_write = 0 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 if 'test_remote' in configuration: messageboard.RemoveSetting(configuration, 'test_remote') def TestDisplayMode(m): SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, m, write_keys, write_format_tuple, link) time.sleep(1) TestDisplayMode(DISP_LAST_FLIGHT_NUMB_ORIG_DEST) TestDisplayMode(DISP_LAST_FLIGHT_AZIMUTH_ELEVATION) TestDisplayMode(DISP_FLIGHT_COUNT_LAST_SEEN) TestDisplayMode(DISP_RADIO_RANGE) if time.time() >= next_write: next_write = SendRemoteMessage( flight, json_desc_dict, configuration, additional_attr, display_mode, write_keys, write_format_tuple, link) <----SKIPPED LINES----> |