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
18import atexit
19import base64
20import datetime
21import json
22import os
23import random
24import subprocess
25import time
26import traceback
27
28from apiclient.discovery import build_from_document
29from apiclient.errors import HttpError
30import httplib2
31from oauth2client.client import AccessTokenRefreshError
32from oauth2client.client import OAuth2WebServerFlow
33from oauth2client.file import Storage
34from tornado.httpserver import HTTPServer
35from 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
48DEVICE_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
69wpa_supplicant_cmd = 'wpa_supplicant -Dwext -i%s -cwpa_supplicant.conf'
70ifconfig_cmd = 'ifconfig %s 192.168.0.3'
71hostapd_cmd = 'hostapd hostapd-min.conf'
72dhclient_release = 'dhclient -r %s'
73dhclient_renew = 'dhclient %s'
74dhcpd_cmd = 'udhcpd -f udhcpd.conf'
75
76wpa_supplicant_conf = 'wpa_supplicant.conf'
77
78wpa_supplicant_template = """
79network={
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
89hostapd_conf = 'hostapd-min.conf'
90
91hostapd_template = """
92interface=%s
93driver=nl80211
94ssid=%s
95channel=1
96"""
97
98udhcpd_conf = 'udhcpd.conf'
99
100udhcpd_template = """
101start        192.168.0.20
102end          192.168.0.254
103interface    %s
104"""
105
106class DeviceUnregisteredError(Exception):
107  pass
108
109
110def 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
120class 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
146class 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
162class 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
176class 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
213class 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
236class 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
309class 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
329class 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
410class 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
437class 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
485class 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
617def 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
625def 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
633def 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
641def 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
649class 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
1093def 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
1109if __name__ == '__main__':
1110  main()
1111