Home | History | Annotate | Download | only in prototype
      1 #!/usr/bin/env python
      2 # Copyright 2014 The Chromium Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 """Prototype of cloud device with support of local API.
      7 
      8   This prototype has tons of flaws, not the least of which being that it
      9   occasionally will block while waiting for commands to finish. However, this is
     10   a quick sketch.
     11   Script requires following components:
     12     sudo apt-get install python-tornado
     13     sudo apt-get install python-pip
     14     sudo pip install google-api-python-client
     15     sudo pip install ecdsa
     16 """
     17 
     18 import atexit
     19 import base64
     20 import datetime
     21 import json
     22 import os
     23 import random
     24 import subprocess
     25 import time
     26 import traceback
     27 
     28 from apiclient.discovery import build_from_document
     29 from apiclient.errors import HttpError
     30 import httplib2
     31 from oauth2client.client import AccessTokenRefreshError
     32 from oauth2client.client import OAuth2WebServerFlow
     33 from oauth2client.file import Storage
     34 from tornado.httpserver import HTTPServer
     35 from tornado.ioloop import IOLoop
     36 
     37 _OAUTH_SCOPE = 'https://www.googleapis.com/auth/clouddevices'
     38 
     39 _CONFIG_FILE = 'config.json'
     40 _API_DISCOVERY_FILE = 'discovery.json'
     41 _DEVICE_STATE_FILE = 'device_state.json'
     42 
     43 _DEVICE_SETUP_SSID = 'GCD Prototype %02d..Bcamprv'
     44 _DEVICE_NAME = 'GCD Prototype'
     45 _DEVICE_TYPE = 'vendor'
     46 _DEVICE_PORT = 8080
     47 
     48 DEVICE_DRAFT = {
     49     'systemName': 'LEDFlasher',
     50     'deviceKind': 'vendor',
     51     'displayName': _DEVICE_NAME,
     52     'channel': {
     53         'supportedType': 'xmpp'
     54     },
     55     'commandDefs': {
     56         'base': {
     57 # TODO(vitalybuka): find new format for custom commands.
     58 #            'vendorCommands': [{
     59 #                'name': 'flashLED',
     60 #                'parameter': [{
     61 #                    'name': 'times',
     62 #                    'type': 'string'
     63 #                }]
     64 #            }]
     65         }
     66     }
     67 }
     68 
     69 wpa_supplicant_cmd = 'wpa_supplicant -Dwext -i%s -cwpa_supplicant.conf'
     70 ifconfig_cmd = 'ifconfig %s 192.168.0.3'
     71 hostapd_cmd = 'hostapd hostapd-min.conf'
     72 dhclient_release = 'dhclient -r %s'
     73 dhclient_renew = 'dhclient %s'
     74 dhcpd_cmd = 'udhcpd -f udhcpd.conf'
     75 
     76 wpa_supplicant_conf = 'wpa_supplicant.conf'
     77 
     78 wpa_supplicant_template = """
     79 network={
     80   ssid="%s"
     81   scan_ssid=1
     82   proto=WPA RSN
     83   key_mgmt=WPA-PSK
     84   pairwise=CCMP TKIP
     85   group=CCMP TKIP
     86   psk="%s"
     87 }"""
     88 
     89 hostapd_conf = 'hostapd-min.conf'
     90 
     91 hostapd_template = """
     92 interface=%s
     93 driver=nl80211
     94 ssid=%s
     95 channel=1
     96 """
     97 
     98 udhcpd_conf = 'udhcpd.conf'
     99 
    100 udhcpd_template = """
    101 start        192.168.0.20
    102 end          192.168.0.254
    103 interface    %s
    104 """
    105 
    106 class DeviceUnregisteredError(Exception):
    107   pass
    108 
    109 
    110 def ignore_errors(func):
    111   def inner(*args, **kwargs):
    112     try:
    113       func(*args, **kwargs)
    114     except Exception:  # pylint: disable=broad-except
    115       print 'Got error in unsafe function:'
    116       traceback.print_exc()
    117   return inner
    118 
    119 
    120 class CommandWrapperReal(object):
    121   """Command wrapper that executs shell commands."""
    122 
    123   def __init__(self, cmd):
    124     if type(cmd) in [str, unicode]:
    125       cmd = cmd.split()
    126     self.cmd = cmd
    127     self.cmd_str = ' '.join(cmd)
    128     self.process = None
    129 
    130   def start(self):
    131     print 'Start: ', self.cmd_str
    132     if self.process:
    133       self.end()
    134     self.process = subprocess.Popen(self.cmd)
    135 
    136   def wait(self):
    137     print 'Wait: ', self.cmd_str
    138     self.process.wait()
    139 
    140   def end(self):
    141     print 'End: ', self.cmd_str
    142     if self.process:
    143       self.process.terminate()
    144 
    145 
    146 class CommandWrapperFake(object):
    147   """Command wrapper that just prints shell commands."""
    148 
    149   def __init__(self, cmd):
    150     self.cmd_str = ' '.join(cmd)
    151 
    152   def start(self):
    153     print 'Fake start: ', self.cmd_str
    154 
    155   def wait(self):
    156     print 'Fake wait: ', self.cmd_str
    157 
    158   def end(self):
    159     print 'Fake end: ', self.cmd_str
    160 
    161 
    162 class CloudCommandHandlerFake(object):
    163   """Prints devices commands without execution."""
    164 
    165   def __init__(self, ioloop):
    166     pass
    167 
    168   def handle_command(self, command_name, args):
    169     if command_name == 'flashLED':
    170       times = 1
    171       if 'times' in args:
    172         times = int(args['times'])
    173       print 'Flashing LED %d times' % times
    174 
    175 
    176 class CloudCommandHandlerReal(object):
    177   """Executes device commands."""
    178 
    179   def __init__(self, ioloop, led_path):
    180     self.ioloop = ioloop
    181     self.led_path = led_path
    182 
    183   def handle_command(self, command_name, args):
    184     if command_name == 'flashLED':
    185       times = 1
    186       if 'times' in args:
    187         times = int(args['times'])
    188       print 'Really flashing LED %d times' % times
    189       self.flash_led(times)
    190 
    191   @ignore_errors
    192   def flash_led(self, times):
    193     self.set_led(times*2, True)
    194 
    195   def set_led(self, times, value):
    196     """Set led value."""
    197     if not times:
    198       return
    199 
    200     file_trigger = open(os.path.join(self.led_path, 'brightness'), 'w')
    201 
    202     if value:
    203       file_trigger.write('1')
    204     else:
    205       file_trigger.write('0')
    206 
    207     file_trigger.close()
    208 
    209     self.ioloop.add_timeout(datetime.timedelta(milliseconds=500),
    210                             lambda: self.set_led(times - 1, not value))
    211 
    212 
    213 class WifiHandler(object):
    214   """Base class for wifi handlers."""
    215 
    216   class Delegate(object):
    217 
    218     def on_wifi_connected(self, unused_token):
    219       """Token is optional, and all delegates should support it being None."""
    220       raise Exception('Unhandled condition: WiFi connected')
    221 
    222   def __init__(self, ioloop, state, config, setup_ssid, delegate):
    223     self.ioloop = ioloop
    224     self.state = state
    225     self.delegate = delegate
    226     self.setup_ssid = setup_ssid
    227     self.interface = config['wireless_interface']
    228 
    229   def start(self):
    230     raise Exception('Start not implemented!')
    231 
    232   def get_ssid(self):
    233     raise Exception('Get SSID not implemented!')
    234 
    235 
    236 class WifiHandlerReal(WifiHandler):
    237   """Real wifi handler.
    238 
    239      Note that by using CommandWrapperFake, you can run WifiHandlerReal on fake
    240      devices for testing the wifi-specific logic.
    241   """
    242 
    243   def __init__(self, ioloop, state, config, setup_ssid, delegate):
    244     super(WifiHandlerReal, self).__init__(ioloop, state, config,
    245                                           setup_ssid, delegate)
    246 
    247     if config['simulate_commands']:
    248       self.command_wrapper = CommandWrapperFake
    249     else:
    250       self.command_wrapper = CommandWrapperReal
    251     self.hostapd = self.command_wrapper(hostapd_cmd)
    252     self.wpa_supplicant = self.command_wrapper(
    253       wpa_supplicant_cmd % self.interface)
    254     self.dhcpd = self.command_wrapper(dhcpd_cmd)
    255 
    256   def start(self):
    257     if self.state.has_wifi():
    258       self.switch_to_wifi(self.state.ssid(), self.state.password(), None)
    259     else:
    260       self.start_hostapd()
    261 
    262   def start_hostapd(self):
    263     hostapd_config = open(hostapd_conf, 'w')
    264     hostapd_config.write(hostapd_template % (self.interface, self.setup_ssid))
    265     hostapd_config.close()
    266 
    267     self.hostapd.start()
    268     time.sleep(3)
    269     self.run_command(ifconfig_cmd % self.interface)
    270     self.dhcpd.start()
    271 
    272   def switch_to_wifi(self, ssid, passwd, token):
    273     try:
    274       udhcpd_config = open(udhcpd_conf, 'w')
    275       udhcpd_config.write(udhcpd_template % self.interface)
    276       udhcpd_config.close()
    277 
    278       wpa_config = open(wpa_supplicant_conf, 'w')
    279       wpa_config.write(wpa_supplicant_template % (ssid, passwd))
    280       wpa_config.close()
    281 
    282       self.hostapd.end()
    283       self.dhcpd.end()
    284       self.wpa_supplicant.start()
    285       self.run_command(dhclient_release % self.interface)
    286       self.run_command(dhclient_renew % self.interface)
    287 
    288       self.state.set_wifi(ssid, passwd)
    289       self.delegate.on_wifi_connected(token)
    290     except DeviceUnregisteredError:
    291       self.state.reset()
    292       self.wpa_supplicant.end()
    293       self.start_hostapd()
    294 
    295   def stop(self):
    296     self.hostapd.end()
    297     self.wpa_supplicant.end()
    298     self.dhcpd.end()
    299 
    300   def get_ssid(self):
    301     return self.state.get_ssid()
    302 
    303   def run_command(self, cmd):
    304     wrapper = self.command_wrapper(cmd)
    305     wrapper.start()
    306     wrapper.wait()
    307 
    308 
    309 class WifiHandlerPassthrough(WifiHandler):
    310   """Passthrough wifi handler."""
    311 
    312   def __init__(self, ioloop, state, config, setup_ssid, delegate):
    313     super(WifiHandlerPassthrough, self).__init__(ioloop, state, config,
    314                                                  setup_ssid, delegate)
    315 
    316   def start(self):
    317     self.delegate.on_wifi_connected(None)
    318 
    319   def switch_to_wifi(self, unused_ssid, unused_passwd, unused_token):
    320     raise Exception('Should not be reached')
    321 
    322   def stop(self):
    323     pass
    324 
    325   def get_ssid(self):
    326     return 'dummy'
    327 
    328 
    329 class State(object):
    330   """Device state."""
    331 
    332   def __init__(self):
    333     self.oauth_storage_ = Storage('oauth_creds')
    334     self.clear()
    335 
    336   def clear(self):
    337     self.credentials_ = None
    338     self.has_credentials_ = False
    339     self.has_wifi_ = False
    340     self.ssid_ = ''
    341     self.password_ = ''
    342     self.device_id_ = ''
    343 
    344   def reset(self):
    345     self.clear()
    346     self.dump()
    347 
    348   def dump(self):
    349     """Saves device state to file."""
    350     json_obj = {
    351         'has_credentials': self.has_credentials_,
    352         'has_wifi': self.has_wifi_,
    353         'ssid': self.ssid_,
    354         'password': self.password_,
    355         'device_id': self.device_id_
    356     }
    357     statefile = open(_DEVICE_STATE_FILE, 'w')
    358     json.dump(json_obj, statefile)
    359     statefile.close()
    360 
    361     if self.has_credentials_:
    362       self.oauth_storage_.put(self.credentials_)
    363 
    364   def load(self):
    365     if os.path.exists(_DEVICE_STATE_FILE):
    366       statefile = open(_DEVICE_STATE_FILE, 'r')
    367       json_obj = json.load(statefile)
    368       statefile.close()
    369 
    370       self.has_credentials_ = json_obj['has_credentials']
    371       self.has_wifi_ = json_obj['has_wifi']
    372       self.ssid_ = json_obj['ssid']
    373       self.password_ = json_obj['password']
    374       self.device_id_ = json_obj['device_id']
    375 
    376       if self.has_credentials_:
    377         self.credentials_ = self.oauth_storage_.get()
    378 
    379   def set_credentials(self, credentials, device_id):
    380     self.device_id_ = device_id
    381     self.credentials_ = credentials
    382     self.has_credentials_ = True
    383     self.dump()
    384 
    385   def set_wifi(self, ssid, password):
    386     self.ssid_ = ssid
    387     self.password_ = password
    388     self.has_wifi_ = True
    389     self.dump()
    390 
    391   def has_wifi(self):
    392     return self.has_wifi_
    393 
    394   def has_credentials(self):
    395     return self.has_credentials_
    396 
    397   def credentials(self):
    398     return self.credentials_
    399 
    400   def ssid(self):
    401     return self.ssid_
    402 
    403   def password(self):
    404     return self.password_
    405 
    406   def device_id(self):
    407     return self.device_id_
    408 
    409 
    410 class Config(object):
    411   """Configuration parameters (should not change)"""
    412   def __init__(self):
    413     if not os.path.isfile(_CONFIG_FILE):
    414       config = {
    415           'oauth_client_id': '',
    416           'oauth_secret': '',
    417           'api_key': '',
    418           'wireless_interface': ''
    419       }
    420       config_f = open(_CONFIG_FILE + '.sample', 'w')
    421       config_f.write(json.dumps(credentials, sort_keys=True,
    422                                      indent=2, separators=(',', ': ')))
    423       config_f.close()
    424       raise Exception('Missing ' + _CONFIG_FILE)
    425 
    426     config_f = open(_CONFIG_FILE)
    427     config = json.load(config_f)
    428     config_f.close()
    429 
    430     self.config = config
    431 
    432   def __getitem__(self, item):
    433     if item in self.config:
    434       return self.config[item]
    435     return None
    436 
    437 class MDnsWrapper(object):
    438   """Handles mDNS requests to device."""
    439 
    440   def __init__(self, command_wrapper):
    441     self.command_wrapper = command_wrapper
    442     self.avahi_wrapper = None
    443     self.setup_name = None
    444     self.device_id = ''
    445     self.started = False
    446 
    447   def start(self):
    448     self.started = True
    449     self.run_command()
    450 
    451   def get_command(self):
    452     """Return the command to run mDNS daemon."""
    453     cmd = [
    454         'avahi-publish',
    455         '-s', '--subtype=_%s._sub._privet._tcp' % _DEVICE_TYPE,
    456         _DEVICE_NAME, '_privet._tcp', '%s' % _DEVICE_PORT,
    457         'txtvers=3',
    458         'type=%s' % _DEVICE_TYPE,
    459         'ty=%s' % _DEVICE_NAME,
    460         'id=%s' % self.device_id
    461     ]
    462     if self.setup_name:
    463       cmd.append('setup_ssid=' + self.setup_name)
    464     return cmd
    465 
    466   def run_command(self):
    467     if self.avahi_wrapper:
    468       self.avahi_wrapper.end()
    469       self.avahi_wrapper.wait()
    470 
    471     self.avahi_wrapper = self.command_wrapper(self.get_command())
    472     self.avahi_wrapper.start()
    473 
    474   def set_id(self, device_id):
    475     self.device_id = device_id
    476     if self.started:
    477       self.run_command()
    478 
    479   def set_setup_name(self, setup_name):
    480     self.setup_name = setup_name
    481     if self.started:
    482       self.run_command()
    483 
    484 
    485 class CloudDevice(object):
    486   """Handles device registration and commands."""
    487 
    488   class Delegate(object):
    489 
    490     def on_device_started(self):
    491       raise Exception('Not implemented: Device started')
    492 
    493     def on_device_stopped(self):
    494       raise Exception('Not implemented: Device stopped')
    495 
    496   def __init__(self, ioloop, state, config, command_wrapper, delegate):
    497     self.state = state
    498     self.http = httplib2.Http()
    499 
    500     self.oauth_client_id = config['oauth_client_id']
    501     self.oauth_secret = config['oauth_secret']
    502     self.api_key = config['api_key']
    503 
    504     if not os.path.isfile(_API_DISCOVERY_FILE):
    505       raise Exception('Download https://developers.google.com/'
    506                       'cloud-devices/v1/discovery.json')
    507 
    508     f = open(_API_DISCOVERY_FILE)
    509     discovery = f.read()
    510     f.close()
    511     self.gcd = build_from_document(discovery, developerKey=self.api_key,
    512                                    http=self.http)
    513 
    514     self.ioloop = ioloop
    515     self.active = True
    516     self.device_id = None
    517     self.credentials = None
    518     self.delegate = delegate
    519     self.command_handler = command_wrapper
    520 
    521   def try_start(self, token):
    522     """Tries start or register device."""
    523     if self.state.has_credentials():
    524       self.credentials = self.state.credentials()
    525       self.device_id = self.state.device_id()
    526       self.run_device()
    527     elif token:
    528       self.register(token)
    529     else:
    530       print 'Device not registered and has no credentials.'
    531       print 'Waiting for registration.'
    532 
    533   def register(self, token):
    534     """Register device."""
    535     resource = {
    536         'deviceDraft': DEVICE_DRAFT,
    537         'oauthClientId': self.oauth_client_id
    538     }
    539 
    540     self.gcd.registrationTickets().patch(registrationTicketId=token,
    541                                          body=resource).execute()
    542 
    543     final_ticket = self.gcd.registrationTickets().finalize(
    544         registrationTicketId=token).execute()
    545 
    546     authorization_code = final_ticket['robotAccountAuthorizationCode']
    547     flow = OAuth2WebServerFlow(self.oauth_client_id, self.oauth_secret,
    548                                _OAUTH_SCOPE, redirect_uri='oob')
    549     self.credentials = flow.step2_exchange(authorization_code)
    550     self.device_id = final_ticket['deviceDraft']['id']
    551     self.state.set_credentials(self.credentials, self.device_id)
    552     print 'Registered with device_id ', self.device_id
    553 
    554     self.run_device()
    555 
    556   def run_device(self):
    557     """Runs device."""
    558     self.credentials.authorize(self.http)
    559 
    560     try:
    561       self.gcd.devices().get(deviceId=self.device_id).execute()
    562     except HttpError, e:
    563       # Pretty good indication the device was deleted
    564       if e.resp.status == 404:
    565         raise DeviceUnregisteredError()
    566     except AccessTokenRefreshError:
    567       raise DeviceUnregisteredError()
    568 
    569     self.check_commands()
    570     self.delegate.on_device_started()
    571 
    572   def check_commands(self):
    573     """Checks device commands."""
    574     if not self.active:
    575       return
    576     print 'Checking commands...'
    577     commands = self.gcd.commands().list(deviceId=self.device_id,
    578                                         state='queued').execute()
    579 
    580     if 'commands' in commands:
    581       print 'Found ', len(commands['commands']), ' commands'
    582       vendor_command_name = None
    583 
    584       for command in commands['commands']:
    585         try:
    586           if command['name'].startswith('base._'):
    587             vendor_command_name = command['name'][len('base._'):]
    588             if 'parameters' in command:
    589               parameters = command['parameters']
    590             else:
    591               parameters = {}
    592           else:
    593             vendor_command_name = None
    594         except KeyError:
    595           print 'Could not parse vendor command ',
    596           print repr(command)
    597           vendor_command_name = None
    598 
    599         if vendor_command_name:
    600           self.command_handler.handle_command(vendor_command_name, parameters)
    601 
    602         self.gcd.commands().patch(commandId=command['id'],
    603                                   body={'state': 'done'}).execute()
    604     else:
    605       print 'Found no commands'
    606 
    607     self.ioloop.add_timeout(datetime.timedelta(milliseconds=1000),
    608                             self.check_commands)
    609 
    610   def stop(self):
    611     self.active = False
    612 
    613   def get_device_id(self):
    614     return self.device_id
    615 
    616 
    617 def get_only(f):
    618   def inner(self, request, response_func, *args):
    619     if request.method != 'GET':
    620       return False
    621     return f(self, request, response_func, *args)
    622   return inner
    623 
    624 
    625 def post_only(f):
    626   def inner(self, request, response_func, *args):
    627     # if request.method != 'POST':
    628       # return False
    629     return f(self, request, response_func, *args)
    630   return inner
    631 
    632 
    633 def wifi_provisioning(f):
    634   def inner(self, request, response_func, *args):
    635     if self.on_wifi:
    636       return False
    637     return f(self, request, response_func, *args)
    638   return inner
    639 
    640 
    641 def post_provisioning(f):
    642   def inner(self, request, response_func, *args):
    643     if not self.on_wifi:
    644       return False
    645     return f(self, request, response_func, *args)
    646   return inner
    647 
    648 
    649 class WebRequestHandler(WifiHandler.Delegate, CloudDevice.Delegate):
    650   """Handles HTTP requests."""
    651 
    652   class InvalidStepError(Exception):
    653     pass
    654 
    655   class InvalidPackageError(Exception):
    656     pass
    657 
    658   class EncryptionError(Exception):
    659     pass
    660 
    661   class CancelableClosure(object):
    662     """Allows to cancel callbacks."""
    663 
    664     def __init__(self, function):
    665       self.function = function
    666 
    667     def __call__(self):
    668       if self.function:
    669         return self.function
    670       return None
    671 
    672     def cancel(self):
    673       self.function = None
    674 
    675   class DummySession(object):
    676     """Handles sessions."""
    677 
    678     def __init__(self, session_id):
    679       self.session_id = session_id
    680       self.key = None
    681 
    682     def do_step(self, step, package):
    683       if step != 0:
    684         raise self.InvalidStepError()
    685       self.key = package
    686       return self.key
    687 
    688     def decrypt(self, cyphertext):
    689       return json.loads(cyphertext[len(self.key):])
    690 
    691     def encrypt(self, plain_data):
    692       return self.key + json.dumps(plain_data)
    693 
    694     def get_session_id(self):
    695       return self.session_id
    696 
    697     def get_stype(self):
    698       return 'dummy'
    699 
    700     def get_status(self):
    701       return 'complete'
    702 
    703   class EmptySession(object):
    704     """Handles sessions."""
    705 
    706     def __init__(self, session_id):
    707       self.session_id = session_id
    708       self.key = None
    709 
    710     def do_step(self, step, package):
    711       if step != 0 or package != '':
    712         raise self.InvalidStepError()
    713       return ''
    714 
    715     def decrypt(self, cyphertext):
    716       return json.loads(cyphertext)
    717 
    718     def encrypt(self, plain_data):
    719       return json.dumps(plain_data)
    720 
    721     def get_session_id(self):
    722       return self.session_id
    723 
    724     def get_stype(self):
    725       return 'empty'
    726 
    727     def get_status(self):
    728       return 'complete'
    729 
    730   def __init__(self, ioloop, state):
    731     self.config = Config()
    732 
    733     if self.config['on_real_device']:
    734       mdns_wrappers = CommandWrapperReal
    735       wifi_handler = WifiHandlerReal
    736     else:
    737       mdns_wrappers = CommandWrapperReal
    738       wifi_handler = WifiHandlerPassthrough
    739 
    740 
    741     if self.config['led_path']:
    742       cloud_wrapper = CloudCommandHandlerReal(ioloop,
    743                                                       self.config['led_path'])
    744       self.setup_real(self.config['led_path'])
    745     else:
    746       cloud_wrapper = CloudCommandHandlerFake(ioloop)
    747       self.setup_fake()
    748 
    749     self.setup_ssid = _DEVICE_SETUP_SSID % random.randint(0,99)
    750     self.cloud_device = CloudDevice(ioloop, state, self.config,
    751                                     cloud_wrapper, self)
    752     self.wifi_handler = wifi_handler(ioloop, state, self.config,
    753                                      self.setup_ssid, self)
    754     self.mdns_wrapper = MDnsWrapper(mdns_wrappers)
    755     self.on_wifi = False
    756     self.registered = False
    757     self.in_session = False
    758     self.ioloop = ioloop
    759 
    760     self.handlers = {
    761         '/internal/ping': self.do_ping,
    762         '/privet/info': self.do_info,
    763         '/deprecated/wifi/switch': self.do_wifi_switch,
    764         '/privet/v3/session/handshake': self.do_session_handshake,
    765         '/privet/v3/session/cancel': self.do_session_cancel,
    766         '/privet/v3/session/request': self.do_session_call,
    767         '/privet/v3/setup/start':
    768             self.get_insecure_api_handler(self.do_secure_setup_start),
    769         '/privet/v3/setup/cancel':
    770             self.get_insecure_api_handler(self.do_secure_setup_cancel),
    771         '/privet/v3/setup/status':
    772             self.get_insecure_api_handler(self.do_secure_status),
    773     }
    774 
    775     self.current_session = None
    776     self.session_cancel_callback = None
    777     self.session_handlers = {
    778         'dummy': self.DummySession,
    779         'empty': self.EmptySession
    780     }
    781 
    782     self.secure_handlers = {
    783         '/privet/v3/setup/start': self.do_secure_setup_start,
    784         '/privet/v3/setup/cancel': self.do_secure_setup_cancel,
    785         '/privet/v3/setup/status': self.do_secure_status
    786     }
    787 
    788   @staticmethod
    789   def setup_fake():
    790     print 'Skipping device setup'
    791 
    792   @staticmethod
    793   def setup_real(led_path):
    794     file_trigger = open(os.path.join(led_path, 'trigger'), 'w')
    795     file_trigger.write('none')
    796     file_trigger.close()
    797 
    798   def start(self):
    799     self.wifi_handler.start()
    800     self.mdns_wrapper.set_setup_name(self.setup_ssid)
    801     self.mdns_wrapper.start()
    802 
    803   @get_only
    804   def do_ping(self, unused_request, response_func):
    805     response_func(200, {'pong': True})
    806     return True
    807 
    808   @get_only
    809   def do_public_info(self, unused_request, response_func):
    810     info = dict(self.get_common_info().items() + {
    811         'stype': self.session_handlers.keys()}.items())
    812     response_func(200, info)
    813 
    814   @get_only
    815   def do_info(self, unused_request, response_func):
    816     specific_info = {
    817         'x-privet-token': 'sample',
    818         'api': sorted(self.handlers.keys())
    819     }
    820     info = dict(self.get_common_info().items() + specific_info.items())
    821     response_func(200, info)
    822     return True
    823 
    824   @post_only
    825   @wifi_provisioning
    826   def do_wifi_switch(self, request, response_func):
    827     """Handles /deprecated/wifi/switch requests."""
    828     data = json.loads(request.body)
    829     try:
    830       ssid = data['ssid']
    831       passw = data['passw']
    832     except KeyError:
    833       print 'Malformed content: ' + repr(data)
    834       response_func(400, {'error': 'invalidParams'})
    835       traceback.print_exc()
    836       return True
    837 
    838     response_func(200, {'ssid': ssid})
    839     self.wifi_handler.switch_to_wifi(ssid, passw, None)
    840     # TODO(noamsml): Return to normal wifi after timeout (cancelable)
    841     return True
    842 
    843   @post_only
    844   def do_session_handshake(self, request, response_func):
    845     """Handles /privet/v3/session/handshake requests."""
    846 
    847     data = json.loads(request.body)
    848     try:
    849       stype = data['keyExchangeType']
    850       step = data['step']
    851       package = base64.b64decode(data['package'])
    852       if 'sessionID' in data:
    853         session_id = data['sessionID']
    854       else:
    855         session_id = "dummy"
    856     except (KeyError, TypeError):
    857       traceback.print_exc()
    858       print 'Malformed content: ' + repr(data)
    859       response_func(400, {'error': 'invalidParams'})
    860       return True
    861 
    862     if self.current_session:
    863       if session_id != self.current_session.get_session_id():
    864         response_func(400, {'error': 'maxSessionsExceeded'})
    865         return True
    866       if stype != self.current_session.get_stype():
    867         response_func(400, {'error': 'unsupportedKeyExchangeType'})
    868         return True
    869     else:
    870       if stype not in self.session_handlers:
    871         response_func(400, {'error': 'unsupportedKeyExchangeType'})
    872         return True
    873       self.current_session = self.session_handlers[stype](session_id)
    874 
    875     try:
    876       output_package = self.current_session.do_step(step, package)
    877     except self.InvalidStepError:
    878       response_func(400, {'error': 'invalidStep'})
    879       return True
    880     except self.InvalidPackageError:
    881       response_func(400, {'error': 'invalidPackage'})
    882       return True
    883 
    884     return_obj = {
    885         'status': self.current_session.get_status(),
    886         'step': step,
    887         'package': base64.b64encode(output_package),
    888         'sessionID': session_id
    889     }
    890     response_func(200, return_obj)
    891     self.post_session_cancel()
    892     return True
    893 
    894   @post_only
    895   def do_session_cancel(self, request, response_func):
    896     """Handles /privet/v3/session/cancel requests."""
    897     data = json.loads(request.body)
    898     try:
    899       session_id = data['sessionID']
    900     except KeyError:
    901       response_func(400, {'error': 'invalidParams'})
    902       return True
    903 
    904     if self.current_session and session_id == self.current_session.session_id:
    905       self.current_session = None
    906       if self.session_cancel_callback:
    907         self.session_cancel_callback.cancel()
    908       response_func(200, {'status': 'cancelled', 'sessionID': session_id})
    909     else:
    910       response_func(400, {'error': 'unknownSession'})
    911     return True
    912 
    913   @post_only
    914   def do_session_call(self, request, response_func):
    915     """Handles /privet/v3/session/call requests."""
    916     try:
    917       session_id = request.headers['X-Privet-SessionID']
    918     except KeyError:
    919       response_func(400, {'error': 'unknownSession'})
    920       return True
    921 
    922     if (not self.current_session or
    923         session_id != self.current_session.session_id):
    924       response_func(400, {'error': 'unknownSession'})
    925       return True
    926 
    927     try:
    928       decrypted = self.current_session.decrypt(request.body)
    929     except self.EncryptionError:
    930       response_func(400, {'error': 'encryptionError'})
    931       return True
    932 
    933     def encrypted_response_func(code, data):
    934       if 'error' in data:
    935         self.encrypted_send_response(request, code, dict(data.items() + {
    936             'api': decrypted['api']
    937         }.items()))
    938       else:
    939         self.encrypted_send_response(request, code, {
    940             'api': decrypted['api'],
    941             'output': data
    942         })
    943 
    944     if ('api' not in decrypted or 'input' not in decrypted or
    945         type(decrypted['input']) != dict):
    946       print 'Invalid params in API stage'
    947       encrypted_response_func(200, {'error': 'invalidParams'})
    948       return True
    949 
    950     if decrypted['api'] in self.secure_handlers:
    951       self.secure_handlers[decrypted['api']](request,
    952                                              encrypted_response_func,
    953                                              decrypted['input'])
    954     else:
    955       encrypted_response_func(200, {'error': 'unknownApi'})
    956 
    957     self.post_session_cancel()
    958     return True
    959 
    960   def get_insecure_api_handler(self, handler):
    961     def inner(request, func):
    962       return self.insecure_api_handler(request, func, handler)
    963     return inner
    964 
    965   @post_only
    966   def insecure_api_handler(self, request, response_func, handler):
    967     real_params = json.loads(request.body) if request.body else {}
    968     handler(request, response_func, real_params)
    969     return True
    970 
    971   def do_secure_status(self, unused_request, response_func, unused_params):
    972     """Handles /privet/v3/setup/status requests."""
    973     setup = {
    974         'registration': {
    975             'required': True
    976         },
    977         'wifi': {
    978             'required': True
    979         }
    980     }
    981     if self.on_wifi:
    982       setup['wifi']['status'] = 'complete'
    983       setup['wifi']['ssid'] = ''  # TODO(noamsml): Add SSID to status
    984     else:
    985       setup['wifi']['status'] = 'available'
    986 
    987     if self.cloud_device.get_device_id():
    988       setup['registration']['status'] = 'complete'
    989       setup['registration']['id'] = self.cloud_device.get_device_id()
    990     else:
    991       setup['registration']['status'] = 'available'
    992     response_func(200, setup)
    993 
    994   def do_secure_setup_start(self, unused_request, response_func, params):
    995     """Handles /privet/v3/setup/start requests."""
    996     has_wifi = False
    997     token = None
    998 
    999     try:
   1000       if 'wifi' in params:
   1001         has_wifi = True
   1002         ssid = params['wifi']['ssid']
   1003         passw = params['wifi']['passphrase']
   1004 
   1005       if 'registration' in params:
   1006         token = params['registration']['ticketID']
   1007     except KeyError:
   1008       print 'Invalid params in bootstrap stage'
   1009       response_func(400, {'error': 'invalidParams'})
   1010       return
   1011 
   1012     try:
   1013       if has_wifi:
   1014         self.wifi_handler.switch_to_wifi(ssid, passw, token)
   1015       elif token:
   1016         self.cloud_device.register(token)
   1017       else:
   1018         response_func(400, {'error': 'invalidParams'})
   1019         return
   1020     except HttpError as e:
   1021       print e  # TODO(noamsml): store error message in this case
   1022 
   1023     self.do_secure_status(unused_request, response_func, params)
   1024 
   1025   def do_secure_setup_cancel(self, request, response_func, params):
   1026     pass
   1027 
   1028   def handle_request(self, request):
   1029     def response_func(code, data):
   1030       self.real_send_response(request, code, data)
   1031 
   1032     handled = False
   1033     print '[INFO] %s %s' % (request.method, request.path)
   1034     if request.path in self.handlers:
   1035       handled = self.handlers[request.path](request, response_func)
   1036 
   1037     if not handled:
   1038       self.real_send_response(request, 404, {'error': 'notFound'})
   1039 
   1040   def encrypted_send_response(self, request, code, data):
   1041     self.raw_send_response(request, code,
   1042                            self.current_session.encrypt(data))
   1043 
   1044   def real_send_response(self, request, code, data):
   1045     data = json.dumps(data, sort_keys=True, indent=2, separators=(',', ': '))
   1046     data += '\n'
   1047     self.raw_send_response(request, code, data)
   1048 
   1049   def raw_send_response(self, request, code, data):
   1050     request.write('HTTP/1.1 %d Maybe OK\n' % code)
   1051     request.write('Content-Type: application/json\n')
   1052     request.write('Content-Length: %s\n\n' % len(data))
   1053     request.write(data)
   1054     request.finish()
   1055 
   1056   def device_state(self):
   1057     return 'idle'
   1058 
   1059   def get_common_info(self):
   1060     return {
   1061         'version': '3.0',
   1062         'name': 'Sample Device',
   1063         'device_state': self.device_state()
   1064     }
   1065 
   1066   def post_session_cancel(self):
   1067     if self.session_cancel_callback:
   1068       self.session_cancel_callback.cancel()
   1069     self.session_cancel_callback = self.CancelableClosure(self.session_cancel)
   1070     self.ioloop.add_timeout(datetime.timedelta(minutes=2),
   1071                             self.session_cancel_callback)
   1072 
   1073   def session_cancel(self):
   1074     self.current_session = None
   1075 
   1076   # WifiHandler.Delegate implementation
   1077   def on_wifi_connected(self, token):
   1078     self.mdns_wrapper.set_setup_name(None)
   1079     self.cloud_device.try_start(token)
   1080     self.on_wifi = True
   1081 
   1082   def on_device_started(self):
   1083     self.mdns_wrapper.set_id(self.cloud_device.get_device_id())
   1084 
   1085   def on_device_stopped(self):
   1086     pass
   1087 
   1088   def stop(self):
   1089     self.wifi_handler.stop()
   1090     self.cloud_device.stop()
   1091 
   1092 
   1093 def main():
   1094   state = State()
   1095   state.load()
   1096 
   1097   ioloop = IOLoop.instance()
   1098 
   1099   handler = WebRequestHandler(ioloop, state)
   1100   handler.start()
   1101   def logic_stop():
   1102     handler.stop()
   1103   atexit.register(logic_stop)
   1104   server = HTTPServer(handler.handle_request)
   1105   server.listen(_DEVICE_PORT)
   1106 
   1107   ioloop.start()
   1108 
   1109 if __name__ == '__main__':
   1110   main()
   1111