530 lines
19 KiB
Python
530 lines
19 KiB
Python
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
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()
|