# Copyright 2016 Mycroft AI, Inc. # # This file is part of Mycroft Core. # # Mycroft Core is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Mycroft Core is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Mycroft Core. If not, see . """ This module implements a mechanism that allows the wifi connection of a Linux system to be selected by end users. This is achieved by: * creating a websocket for communication between the pieces of this mechanism * temporarilly creating a virtual access point * directing the end user to connect to that access point with another device (phone or tablet or laptop) * having them open a captive portal in that device's web browser * selecting the desired wifi within that browser * configuring this device based on that selection """ import os import sys import threading import time import traceback from SimpleHTTPServer import SimpleHTTPRequestHandler from SocketServer import TCPServer from os.path import dirname, realpath from shutil import copyfile from subprocess import Popen, PIPE from threading import Thread from time import sleep from pyric import pyw from wifi import Cell from mycroft.client.enclosure.api import EnclosureAPI from mycroft.configuration import ConfigurationManager from mycroft.messagebus.client.ws import WebsocketClient from mycroft.messagebus.message import Message from mycroft.util import connected from mycroft.util.log import getLogger __author__ = 'aatchison and penrods' LOG = getLogger("WiFiClient") SCRIPT_DIR = dirname(realpath(__file__)) def cli_no_output(*args): ''' Invoke a command line and return result ''' LOG.info("Command: %s" % list(args)) proc = Popen(args=args, stdout=PIPE, stderr=PIPE) stdout, stderr = proc.communicate() return {'code': proc.returncode, 'stdout': stdout, 'stderr': stderr} def cli(*args): ''' Invoke a command line, then log and return result ''' LOG.info("Command: %s" % list(args)) proc = Popen(args=args, stdout=PIPE, stderr=PIPE) stdout, stderr = proc.communicate() result = {'code': proc.returncode, 'stdout': stdout, 'stderr': stderr} LOG.info("Command result: %s" % result) return result def wpa(*args): idx = 0 result = cli('wpa_cli', '-i', *args) out = result.get("stdout", "\n") if "interface" in out: idx = 1 return str(out.split("\n")[idx]) def sysctrl(*args): return cli('systemctl', *args) class CaptiveHTTPRequestHandler(SimpleHTTPRequestHandler): ''' Serve a single website, 303 redirecting all other requests to it ''' def do_HEAD(self): LOG.info("do_HEAD being called....") if not self.redirect(): SimpleHTTPRequestHandler.do_HEAD(self) def do_GET(self): LOG.info("do_GET being called....") if not self.redirect(): SimpleHTTPRequestHandler.do_GET(self) def redirect(self): try: LOG.info("***********************") LOG.info("** HTTP Request ***") LOG.info("***********************") LOG.info("Requesting: " + self.path) LOG.info("REMOTE_ADDR:" + self.client_address[0]) LOG.info("SERVER_NAME:" + self.server.server_address[0]) LOG.info("SERVER_PORT:" + str(self.server.server_address[1])) LOG.info("SERVER_PROTOCOL:" + self.request_version) LOG.info("HEADERS...") LOG.info(self.headers) LOG.info("***********************") # path = self.translate_path(self.path) if "mycroft.ai" in self.headers['host']: LOG.info("No redirect") return False else: LOG.info("303 redirect to http://start.mycroft.ai") self.send_response(303) self.send_header("Location", "http://start.mycroft.ai") self.end_headers() return True except: tb = traceback.format_exc() LOG.info("exception caught") LOG.info(tb) return False class WebServer(Thread): ''' Web server for devices connected to the temporary access point ''' def __init__(self, host, port): super(WebServer, self).__init__() self.daemon = True LOG.info("Creating TCPServer...") self.server = TCPServer((host, port), CaptiveHTTPRequestHandler) LOG.info("Created TCPServer") def run(self): LOG.info("Starting Web Server at %s:%s" % self.server.server_address) LOG.info("Serving from: %s" % os.path.join(SCRIPT_DIR, 'web')) os.chdir(os.path.join(SCRIPT_DIR, 'web')) self.server.serve_forever() LOG.info("Web Server stopped!") class AccessPoint: template = """interface={interface} bind-interfaces server={server} domain-needed bogus-priv dhcp-range={dhcp_range_start}, {dhcp_range_end}, 12h address=/#/{server} """ def __init__(self, wiface): self.wiface = wiface self.iface = 'p2p-wlan0-0' self.subnet = '172.24.1' self.ip = self.subnet + '.1' self.ip_start = self.subnet + '.50' self.ip_end = self.subnet + '.150' self.password = None def up(self): try: card = pyw.getcard(self.iface) except: wpa(self.wiface, 'p2p_group_add', 'persistent=0') self.iface = self.get_iface() self.password = wpa(self.iface, 'p2p_get_passphrase') card = pyw.getcard(self.iface) pyw.inetset(card, self.ip) copyfile('/etc/dnsmasq.conf', '/tmp/dnsmasq-bk.conf') self.save() sysctrl('restart', 'dnsmasq.service') def get_iface(self): for iface in pyw.winterfaces(): if "p2p" in iface: return iface def down(self): sysctrl('stop', 'dnsmasq.service') sysctrl('disable', 'dnsmasq.service') wpa(self.wiface, 'p2p_group_remove', self.iface) copyfile('/tmp/dnsmasq-bk.conf', '/etc/dnsmasq.conf') def save(self): data = { "interface": self.iface, "server": self.ip, "dhcp_range_start": self.ip_start, "dhcp_range_end": self.ip_end } try: LOG.info("Writing to: /etc/dnsmasq.conf") with open('/etc/dnsmasq.conf', 'w') as f: f.write(self.template.format(**data)) except Exception as e: LOG.error("Fail to write: /etc/dnsmasq.conf") raise e class WiFi: def __init__(self): self.iface = pyw.winterfaces()[0] self.ap = AccessPoint(self.iface) self.server = None self.ws = WebsocketClient() ConfigurationManager.init(self.ws) self.enclosure = EnclosureAPI(self.ws) self.init_events() self.conn_monitor = None self.conn_monitor_stop = threading.Event() def init_events(self): ''' Register handlers for various websocket events used to communicate with outside systems. ''' # This event is generated by an outside mechanism. On a # Holmes unit this comes from the Enclosure's menu item # being selected. self.ws.on('mycroft.wifi.start', self.start) # These events are generated by Javascript in the captive # portal. self.ws.on('mycroft.wifi.stop', self.stop) self.ws.on('mycroft.wifi.scan', self.scan) self.ws.on('mycroft.wifi.connect', self.connect) def start(self, event=None): ''' Fire up the MYCROFT access point for the user to connect to with a phone or computer. ''' LOG.info("Starting access point...") # Fire up our access point self.ap.up() if not self.server: LOG.info("Creating web server...") self.server = WebServer(self.ap.ip, 80) LOG.info("Starting web server...") self.server.start() LOG.info("Created web server.") LOG.info("Access point started!\n%s" % self.ap.__dict__) self._start_connection_monitor() def _connection_prompt(self, prefix): # let the user know to connect to it... passwordSpelled = ", ".join(self.ap.password) self._speak_and_show( prefix + " Use your mobile device or computer to " "connect to the wifi network " "'MYCROFT'; Then enter the uppercase " "password " + passwordSpelled, self.ap.password) def _speak_and_show(self, speak, show): ''' Communicate with the user throughout the process ''' self.ws.emit(Message("speak", {'utterance': speak})) if show is None: return # TODO: This sleep should not be necessary, but without it the # text to be displayed by enclosure.mouth_text() gets # wiped out immediately when the utterance above is # begins processing. # Remove the sleep once this behavior is corrected. sleep(0.25) self.enclosure.mouth_text(show) def _start_connection_monitor(self): LOG.info("Starting monitor thread...\n") if self.conn_monitor is not None: LOG.info("Killing old thread...\n") self.conn_monitor_stop.set() self.conn_monitor_stop.wait() self.conn_monitor = threading.Thread( target=self._do_connection_monitor, args={}) self.conn_monitor.daemon = True self.conn_monitor.start() LOG.info("Monitor thread setup complete.\n") def _stop_connection_monitor(self): ''' Set flag that will let monitoring thread close ''' self.conn_monitor_stop.set() def _do_connection_monitor(self): LOG.info("Invoked monitor thread...\n") mtimeLast = os.path.getmtime('/var/lib/misc/dnsmasq.leases') bHasConnected = False cARPFailures = 0 timeStarted = time.time() timeLastAnnounced = 0 # force first announcement to now self.conn_monitor_stop.clear() while not self.conn_monitor_stop.isSet(): # do our monitoring... mtime = os.path.getmtime('/var/lib/misc/dnsmasq.leases') if mtimeLast != mtime: # Something changed in the dnsmasq lease file - # presumably a (re)new lease bHasConnected = True cARPFailures = 0 mtimeLast = mtime timeStarted = time.time() # reset start time after connection timeLastAnnounced = time.time() - 45 # announce how to connect if time.time() - timeStarted > 60 * 5: # After 5 minutes, shut down the access point LOG.info("Auto-shutdown of access point after 5 minutes") self.stop() continue if time.time() - timeLastAnnounced >= 45: if bHasConnected: self._speak_and_show( "Now you can open your browser and go to start dot " "mycroft dot A I, then follow the instructions given " " there", "start.mycroft.ai") else: self._connection_prompt("Allow me to walk you through the " " wifi setup process; ") timeLastAnnounced = time.time() if bHasConnected: # Flush the ARP entries associated with our access point # This will require all network hardware to re-register # with the ARP tables if still present. if cARPFailures == 0: res = cli_no_output('ip', '-s', '-s', 'neigh', 'flush', self.ap.subnet + '.0/24') # Give ARP system time to re-register hardware sleep(5) # now look at the hardware that has responded, if no entry # shows up on our access point after 2*5=10 seconds, the user # has disconnected if not self._is_ARP_filled(): cARPFailures += 1 if cARPFailures > 2: self._connection_prompt("Connection lost,") bHasConnected = False else: cARPFailures = 0 sleep(5) # wait a bit to prevent thread from hogging CPU LOG.info("Exiting monitor thread...\n") self.conn_monitor_stop.clear() def _is_ARP_filled(self): res = cli_no_output('/usr/sbin/arp', '-n') out = str(res.get("stdout")) if out: # Parse output, skipping header for o in out.split("\n")[1:]: if o[0:len(self.ap.subnet)] == self.ap.subnet: if "(incomplete)" in o: # ping the IP to get the ARP table entry reloaded ip_disconnected = o.split(" ")[0] cli_no_output('/bin/ping', '-c', '1', '-W', '3', ip_disconnected) else: return True # something on subnet is connected! return False def scan(self, event=None): LOG.info("Scanning wifi connections...") networks = {} status = self.get_status() for cell in Cell.all(self.iface): update = True ssid = cell.ssid quality = self.get_quality(cell.quality) # If there are duplicate network IDs (e.g. repeaters) only # report the strongest signal if networks.__contains__(ssid): update = networks.get(ssid).get("quality") < quality if update and ssid: networks[ssid] = { 'quality': quality, 'encrypted': cell.encrypted, 'connected': self.is_connected(ssid, status) } self.ws.emit(Message("mycroft.wifi.scanned", {'networks': networks})) LOG.info("Wifi connections scanned!\n%s" % networks) @staticmethod def get_quality(quality): values = quality.split("/") return float(values[0]) / float(values[1]) def connect(self, event=None): if event and event.data: ssid = event.data.get("ssid") connected = self.is_connected(ssid) if connected: LOG.warn("Mycroft is already connected to %s" % ssid) else: self.disconnect() LOG.info("Connecting to: %s" % ssid) nid = wpa(self.iface, 'add_network') wpa(self.iface, 'set_network', nid, 'ssid', '"' + ssid + '"') if event.data.__contains__("pass"): psk = '"' + event.data.get("pass") + '"' wpa(self.iface, 'set_network', nid, 'psk', psk) else: wpa(self.iface, 'set_network', nid, 'key_mgmt', 'NONE') wpa(self.iface, 'enable', nid) connected = self.get_connected(ssid) if connected: wpa(self.iface, 'save_config') self.ws.emit(Message("mycroft.wifi.connected", {'connected': connected})) LOG.info("Connection status for %s = %s" % (ssid, connected)) if connected: self.ws.emit(Message("speak", { 'utterance': "Thank you, I'm now connected to the " "internet and ready for use"})) # TODO: emit something that triggers a pairing check def disconnect(self): status = self.get_status() nid = status.get("id") if nid: ssid = status.get("ssid") wpa(self.iface, 'disable', nid) LOG.info("Disconnecting %s id: %s" % (ssid, nid)) def get_status(self): res = cli('wpa_cli', '-i', self.iface, 'status') out = str(res.get("stdout")) if out: return dict(o.split("=") for o in out.split("\n")[:-1]) return {} def get_connected(self, ssid, retry=5): connected = self.is_connected(ssid) while not connected and retry > 0: sleep(2) retry -= 1 connected = self.is_connected(ssid) return connected def is_connected(self, ssid, status=None): status = status or self.get_status() state = status.get("wpa_state") return status.get("ssid") == ssid and state == "COMPLETED" def stop(self, event=None): LOG.info("Stopping access point...") self._stop_connection_monitor() self.ap.down() if self.server: self.server.server.shutdown() self.server.server.server_close() self.server.join() self.server = None LOG.info("Access point stopped!") def _do_net_check(self): # give system 5 seconds to resolve network or get plugged in sleep(5) LOG.info("Checking internet connection again") if not connected() and self.conn_monitor is None: # TODO: Enclosure/localization self._speak_and_show( "This device is not connected to the Internet. Either plug " "in a network cable or hold the button on top for two " "seconds, then select wifi from the menu", None) def run(self): try: # When the system first boots up, check for a valid internet # connection. LOG.info("Checking internet connection") if not connected(): LOG.info("No connection initially, waiting 20...") self.net_check = threading.Thread( target=self._do_net_check, args={}) self.net_check.daemon = True self.net_check.start() else: LOG.info("Connection found!") self.ws.run_forever() except Exception as e: LOG.error("Error: {0}".format(e)) self.stop() def main(): wifi = WiFi() try: wifi.run() except Exception as e: print(e) finally: sys.exit() if __name__ == "__main__": main()