arduino-2020-05-22-2250.py
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)))