#!/usr/bin/env python # standard python import ConfigParser import logging import logging.config import re import select import socket import struct import sys import time # sololink, in /usr/bin import clock import hostapd_ctrl import ip_util import lockout_msg import pair import runlevel # as printed to console and logs prog_name = "pair_server" sololink_conf = "/etc/sololink.conf" pairing_conf = "/log/3dr-pairing.conf" sololink_version_file = "/VERSION" firmware_version_file = "/STM_VERSION" solo_sololink_version_file = "/tmp/PEER_SL_VERSION" solo_firmware_version_file = "/tmp/PEER_FW_VERSION" logging.config.fileConfig(sololink_conf) logger = logging.getLogger("pair") logger.info("%s starting", prog_name) sololink_config = ConfigParser.SafeConfigParser() pairing_config = ConfigParser.SafeConfigParser() sololink_config.read(sololink_conf) pairing_config.read(pairing_conf) # read configuration items try: # controller_link_port = 5501 controller_link_port = \ sololink_config.getint("pairing", "controller_link_port") # solo_address_file = "/var/run/solo.ip" solo_address_file = sololink_config.get("pairing", "solo_address_file") # user_confirmation_timeout = 30.0 user_confirmation_timeout = \ sololink_config.getfloat("pairing", "user_confirmation_timeout") # pair_req_port = 5013 pair_req_port = sololink_config.getint("solo", "pairReqDestPort") # pair_res_port = 5014 pair_res_port = sololink_config.getint("solo", "pairResDestPort") except: logger.error("error reading config from %s", sololink_conf) sys.exit(1) try: check_versions = sololink_config.getboolean("solo", "pairCheckVersions") except: check_versions = True # default logger.info("using default check_versions=%s", str(check_versions)) # read sololink version try: f = open(sololink_version_file, 'r') controller_sololink_version = f.readline() # still has \n controller_sololink_version = controller_sololink_version.strip('\r\n\t\0 ') except: logger.error("error reading version from %s", sololink_version_file) sys.exit(1) logger.info("sololink version \"%s\"", controller_sololink_version) # read firmware version try: f = open(firmware_version_file, 'r') controller_firmware_version = f.readline() # still has \n controller_firmware_version = controller_firmware_version.strip('\r\n\t\0 ') except: logger.error("error reading version from %s", firmware_version_file) controller_firmware_version = "unknown" logger.info("firmware version \"%s\"", controller_firmware_version) user_wait_timeout_us = int(user_confirmation_timeout * 1000000) user_wait_start_us = None # It is not critical that the PIN be secret. The reason for using WPS PIN is # not the PIN's security (it is not), but that we can cause hostapd to ask us # for the PIN, at which time get confirmation from the user. secret_pin = 74015887 # States the controller side can be in # STATE_IDLE: have not heard from a Solo yet # STATE_USER_WAIT: have asked for user approval for a Solo # STATE_CONNECTED: have connected to a Solo # # Events causing state transitions # hostapd: pin request # solo: connect request # stm32: pair confirm # timeout (waiting for user) # # Conditions affecting state transitions # is message from the paired solo # is message from the connected solo # # Actions to take on state transitions # send pair request to stm32 # send pair result to stm32 # send pin response to hostapd # send connect/yes to solo # send connect/no to solo # send connect/pend to solo # set paired solo # set connected solo # # A "paired solo" is a static thing, stored in a file. It is the Solo that we # will accept a connection from without prompting the user. # # A "connected solo" is one that we have gotten a connect request from and # sent a connect yes to. # # A solo becomes the "paired solo" by sending a pin request and having the # user approve it. The "paired solo" is remembered in a file. # # A solo then becomes the "connected solo" by sending a connect request to # which we respond with a connect yes. The "connected solo" is established # some time after startup, and once established, cannot change until the # controller is power cycled. # # At power on, there might be a paired solo, but there will not be a connected # Solo. STATE_IDLE = 0 STATE_USER_WAIT = 1 STATE_CONNECTED = 2 state_names = [ "IDLE", "USER_WAIT", "CONNECTED" ] def set_state(new_state): global state logger.info("%s -> %s", state_names[state], state_names[new_state]) state = new_state # information about the Solo we are currently talking to class Solo: # uuid: arrives from Solo with the PIN request, and is given back to # hostapd with the PIN reply. Keeping it makes it so hostapd will give the # password only to the Solo that the user approved by name (in case there # happens to be more than one trying to pair at the same time). # # mac: used to identify a paired solo, i.e. once a user approves pairing # with a particular MAC, future requests from the same MAC do not need # approval. # # name: comes from wpa_supplicant.conf on Solo, and is what is presented # to the user for approval. def __init__(self): self.reset() def reset(self): self.name = None self.mac = None self.uuid = None self.need_pin = None self.confirmed = None self.locked = None self.sololink_version = None self.firmware_version = None self.last_msg_us = None def set(self, name, mac): self.name = name self.mac = mac self.uuid = None self.confirmed = None self.locked = None self.sololink_version = None self.firmware_version = None self.last_msg_us = None def set_versions(self, sololink, firmware): if self.sololink_version == sololink and self.firmware_version == firmware: return self.sololink_version = sololink self.firmware_version = firmware try: f = open(solo_sololink_version_file, 'w') f.write("%s\n" % (sololink, )) f.close() except: logger.error("error writing solo sololink version to %s", solo_sololink_version_file) try: f = open(solo_firmware_version_file, 'w') f.write("%s\n" % (firmware, )) f.close() except: logger.error("error writing solo firmware version to %s", solo_firmware_version_file) logger.info("solo version \"%s\"; firmware \"%s\"", solo.sololink_version, solo.firmware_version) solo = Solo() # Set Solo's IP in /var/run/solo.ip, then change runlevels def set_solo(solo_adrs): logger.info("connected with %s", str(solo_adrs)) # write solo address file f = open(solo_address_file, "w") f.write(solo_adrs[0] + "\n") f.close() # Timeouts all use CLOCK_MONOTONIC so as to not be disturbed by jumps # in system time. Internal times all have either _s (seconds) or _us # (microseconds) appended to the name to keep the units straight. Times # from external sources (i.e. config settings) are all floating-point # seconds. # The stm32 module listens on different UDP ports, waiting for messages # to send down to the STM32. # pair request messages stm32_req_sockaddr_remote = ("127.0.0.1", pair_req_port) # pair result messages stm32_res_sockaddr_remote = ("127.0.0.1", pair_res_port) # this server listens on this port; solo contacts this port to pair pair_sockaddr_local = ("", controller_link_port) # These come over in the pin-needed message, and so must match the settings in # Solo's wpa_supplicant.conf file. Requiring these to match just lessens the # liklihood that we get spurious pin-needed events from whoever happens to be # out there. manuf_name = "3D Robotics" model_name = "Solo" # Whether we have changed to runlevel.READY yet or not runlevel_ready = False # Log a version mismatch once per minute version_mismatch_log_time_us = 0 version_mismatch_log_interval_us = 60 * 1000000 # If we are connected to a locked solo, but don't hear from it in this long, # we take down the preflight update screen solo_locked_msg_timeout_us = 3000000 # 3 sec # Solo's that have been rejected since power on. Once a user rejects pairing # for a Solo, that Solo should not cause a prompt again. The blacklist only # lasts until the next reboot. It can't be persistent, in case a user # accidently rejects his own Solo. blacklist = [] # Messages we might send to solo (part of the pairing protocol) conn_ack_no = struct.pack(" 31: name = name[:31] name = name + "\0" logger.debug("sending pair request to stm32 (%s)", name) stm32_sock.sendto(name, stm32_req_sockaddr_remote) def send_pair_result(name): if len(name) > 31: name = name[:31] name = name + "\0" logger.debug("sending pair result to stm32 (%s)", name) stm32_sock.sendto(name, stm32_res_sockaddr_remote) def pin_req_valid(pin_req): global solo # msg is a 9-tuple if len(pin_req) != 9: solo.reset() logger.info("pin request invalid") logger.info(pin_req) return False uuid = pin_req[1] mac = pin_req[2] name = pin_req[3] manufacturer = pin_req[4] model_name = pin_req[5] model_number = pin_req[6] serial_number = pin_req[7] device_type = pin_req[8] # must be a 3DR Solo (allow model names like "Solo 1" or "Solo 2") if manufacturer != "3D Robotics" or model_name.find("Solo") == -1: solo.reset() logger.info("pin request not from a 3DR Solo") logger.info(msg) return False # must have a UUID in the request, since that's used to send back the PIN if uuid is None: solo.reset() logger.info("pin request has no uuid") logger.info(msg) return False # must have a MAC in the request, since that's used to differentiate Solos if mac is None: solo.reset() logger.info("pin request has no mac") logger.info(msg) return False # pin request is valid logger.info("pin request: name=\"%s\", mac=%s, uuid=%s", name, mac, uuid) if name is None or name == "": name = mac solo.uuid = uuid solo.mac = mac solo.name = name return True def check_user_wait_timeout(now_us): user_wait_us = now_us - user_wait_start_us; if user_wait_us > user_wait_timeout_us: # timeout! logger.info("timeout waiting for user (%0.3f sec)", user_wait_us / 1000000.0) send_pair_result("") solo.reset() set_state(STATE_IDLE) # Handle PIN request message from hostapd # # Only called when we are in STATE_IDLE and we got a WPS-PIN-NEEDED message # from hostapd. # # Return next state: # STATE_IDLE (pin request ignored) # STATE_USER_WAIT (asked used to confirm) # STATE_CONNECTED (known solo) def handle_pin_request(now_us, msg): global solo global user_wait_start_us logger.debug("handle_pin_request: %s", msg) if not pin_req_valid(msg): return STATE_IDLE # pin_req_valid sets solo.uuid, solo.mac, and solo.name # Has user rejected this solo since power on? if solo.mac in blacklist: logger.info("pin request from rejected solo; ignoring") return STATE_IDLE # Is this the paired Solo? (hmm, if so, it forgot its pairing) if pairing_config.has_section(solo.mac): logger.info("pin request from known solo") # send pin reply to hostapd hostapd.send_pin(solo.uuid, secret_pin) logger.debug("sending pin for uuid %s", solo.uuid) solo.confirmed = False return STATE_CONNECTED else: logger.info("pin request from unknown solo; need user confirmation") # send pair request to the STM32 solo.need_pin = True send_pair_request(solo.name) user_wait_start_us = now_us return STATE_USER_WAIT # True if user accepted pairing # False if user did not accepted pairing def pair_confirm_answer(stm32_pkt): return (ord(stm32_pkt[0]) != 0) # validate connect request from solo # True if packet valid # False if packet not valid def connect_request_valid(solo_pkt): return (len(solo_pkt) == pair.CONN_MSG_LEN and \ ord(solo_pkt[0]) == pair.CMD_CONN_REQ and \ ord(solo_pkt[1]) == pair.SYS_SOLO and \ ord(solo_pkt[2]) == 0) # not checking solo_pkt[3] (locked flag) # not checking solo_pkt[4:] (version) def set_paired_solo(solo): logger.info("setting %s as paired solo", solo.mac) sections = pairing_config.sections() for s in sections: pairing_config.remove_section(s) pairing_config.add_section(solo.mac) pairing_config.set(solo.mac, "name", solo.name) f = open(pairing_conf, "w") pairing_config.write(f) f.close() def go_ready(): global runlevel_ready if not runlevel_ready: logger.info("switching to runlevel.READY") runlevel.set(runlevel.READY) runlevel_ready = True # stm32 interface socket. Send the pair request to the stm32 module using this # socket, then get a pair confirm from the stm32 module. stm32_sock is not # bound (gets INADDR_ANY and a random port). stm32_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) pair_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) pair_sock.bind(pair_sockaddr_local) # hostapd control interface hostapd = hostapd_ctrl.HostapdCtrl("wlan0-ap") if not hostapd.attach(2.0): logger.error("can't attach to hostapd") sys.exit(1) # # State transition loop # state = STATE_IDLE while True: source_adrs = None timeout = False # Could get message from: # hostapd (pin request) # solo (connect request) # stm32 (pair confirm) # ...or timeout waiting for a message ready = select.select([hostapd.sock, stm32_sock, pair_sock], [], [], 1) # This is the only place the clock is read. If it is needed below (e.g. # for user_wait_start_us), this value is used. Reading it again can lead # to the condition where user_wait_start_us is temporarily later than # now_us (it should always be earlier). now_us = clock.gettime_us(clock.CLOCK_MONOTONIC) if hostapd.sock in ready[0]: # Can get several messages from hostapd, but we only respond to # pin request pkt = hostapd.sock.recv(256) #logger.debug("%s: %s", prog_name, pkt) msg = hostapd.parse(pkt) # Ignore it we are not in STATE_IDLE or if it is not a pin request. if state != STATE_IDLE: logger.debug("ignoring message %s (not STATE_IDLE)", str(msg[0])) elif msg[0] != "WPS-PIN-NEEDED": logger.debug("ignoring message %s", str(msg[0])) else: set_state(handle_pin_request(now_us, msg)) elif stm32_sock in ready[0]: # The only message from the STM32 we respond to is a pair_confirm, # and we only respond to that if we are in STATE_USER_WAIT. pkt = stm32_sock.recv(256) logger.debug("message from stm32: %s", str([ord(c) for c in pkt])) if state != STATE_USER_WAIT: logger.debug("not STATE_USER_WAIT; ignoring message") elif pair_confirm_answer(pkt): logger.debug("user accepted pairing") set_paired_solo(solo) if solo.need_pin: logger.debug("sending pin %d for uuid %s", secret_pin, solo.uuid) hostapd.send_pin(solo.uuid, secret_pin) solo.confirmed = False set_state(STATE_CONNECTED) else: logger.info("user rejected pairing") send_pair_result("") blacklist.append(solo.mac) logger.info("blacklist: %s", str(blacklist)) solo.reset() set_state(STATE_IDLE) elif pair_sock in ready[0]: # message from solo pkt, source_adrs = pair_sock.recvfrom(256) # the only valid message is a connect request if not connect_request_valid(pkt): logger.info("unknown message from %s", str(source_adrs)) # ignore it else: source_mac = ip_util.get_ip_mac(source_adrs[0]) solo_locked = ((ord(pkt[3]) & pair.DATA_LOCKED) != 0) # source_mac might be None if source_mac is not None: logger.debug("connect request from %s", str(source_mac)) if state == STATE_CONNECTED: if source_mac == solo.mac: logger.debug("send CONN_ACK (yes)") pair_sock.sendto(conn_ack_yes, source_adrs) solo.set_versions(pkt[4:36].strip('\r\n\t\0 '), pkt[36:68].strip('\r\n\t\0 ')) if not solo.confirmed: send_pair_result(solo.name) set_solo(source_adrs) solo.confirmed = True # look for changes in solo's lock state if solo.locked is None: # This happens when the user first confirms # pairing; lock is not handled until after the # pairing process (here). if solo_locked: # paired with a locked solo logger.info("connected to locked solo") lockout_msg.send_lock() else: logger.info("connected to unlocked solo") # don't need to send an unlock message elif not solo.locked: # This is the usual case while flying; solo is not # locked, and we'll find solo_locked (from the # message) to be false if solo_locked: # solo was unlocked, but is now locked # uncommon! logger.info("solo is now locked") lockout_msg.send_lock() else: # solo.locked if not solo_locked: # solo was locked, and is now unlocked # common when factory testing logger.info("solo is now unlocked") lockout_msg.send_unlock() solo.locked = solo_locked # check for version mismatch if check_versions and (solo.sololink_version != controller_sololink_version): # version mismatch - update required # this really only needs to be sent once lockout_msg.send_lock() if now_us > version_mismatch_log_time_us: logger.info("version mismatch: solo=\"%s\", controller=\"%s\"", solo.sololink_version, controller_sololink_version) version_mismatch_log_time_us = now_us + version_mismatch_log_interval_us # XXX updating solo to matching version without # restarting controller is probably not handled # correctly # change runlevel even if locked or versions incompatible # apps look better if there is telemetry flowing and shotmgr is running go_ready() # remember the last time we heard from the connected solo solo.last_msg_us = now_us else: # we have a connected solo, but the message is from a different solo logger.info("send CONN_ACK (no)") pair_sock.sendto(conn_ack_no, source_adrs) elif state == STATE_IDLE: # is this the paired Solo? if pairing_config.has_section(source_mac): logger.info("connection request from known solo: " + \ "send CONN_ACK (yes)") pair_sock.sendto(conn_ack_yes, source_adrs) name = pairing_config.get(source_mac, "name") solo.set(name, source_mac) solo.confirmed = True solo.locked = solo_locked # strip whitespace and \0 solo.set_versions(pkt[4:36].strip('\r\n\t\0 '), pkt[36:68].strip('\r\n\t\0 ')) set_state(STATE_CONNECTED) set_solo(source_adrs) if solo.locked: # solo is locked - update required lockout_msg.send_lock() logger.info("connected to locked solo: solo=\"%s\", controller=\"%s\"", solo.sololink_version, controller_sololink_version) elif check_versions and (solo.sololink_version != controller_sololink_version): # version mismatch - update required lockout_msg.send_lock() logger.info("version mismatch: solo=\"%s\", controller=\"%s\"", solo.sololink_version, controller_sololink_version) version_mismatch_log_time_us = now_us + version_mismatch_log_interval_us # change runlevel even if locked or versions incompatible # apps look better if there is telemetry flowing and shotmgr is running go_ready() # remember the last time we heard from the connected solo solo.last_msg_us = now_us # There is an edge case here, probably only stimulated # by forcing it while testing: Artoo is entering its # CONNECTED state. If one unpairs the Solo and reboots # it, then Solo will come up and when the pair button is # pressed, send a PIN request. This Artoo will not respond # to it because it went straight from IDLE to CONNECTED. # We cannot pre-load the PIN into hostapd at this point # because we do not know the Solo's UUID. Artoo must be # restarted to handle that case. elif source_mac in blacklist: # User has rejected this Solo since power on logger.info("connection request from rejected solo: " + \ "send CONN_ACK (no)") pair_sock.sendto(conn_ack_no, source_adrs) # stay in IDLE else: logger.info("connection request from unknown solo: " + \ "send CONN_ACK (pending)") pair_sock.sendto(conn_ack_pend, source_adrs) # get from connect request message # XXX generate it for now name = "Solo " + source_mac[9:11] + \ source_mac[12:14] + source_mac[15:17] solo.set(name, source_mac) solo.need_pin = False send_pair_request(solo.name) user_wait_start_us = now_us # Don't check lockout and don't set solo.locked; need # to allow pairing screens. Lockout is checked after # pairing is confirmed. set_state(STATE_USER_WAIT) else: # STATE_USER_WAIT if solo.mac == source_mac: pair_sock.sendto(conn_ack_pend, source_adrs) else: pair_sock.sendto(conn_ack_no, source_adrs) ### if source_mac is not None # all cases: check for timeout in STATE_USER_WAIT if state == STATE_USER_WAIT: check_user_wait_timeout(now_us) # All cases: check for timeout since we last heard from connected solo # The purpose of this check is for the locked build case: If solo is # turned off, we want to switch from "preflight update" to "waiting for # solo". if state == STATE_CONNECTED and \ solo.locked and \ (now_us - solo.last_msg_us) > solo_locked_msg_timeout_us: # this causes the desired screen switch, but we stat connected solo.locked = None lockout_msg.send_unlock() # end while