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.
6 """Prototype of cloud device with support of local API.
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
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
27 from apiclient.discovery import build_from_document
28 from apiclient.errors import HttpError
30 from oauth2client.client import AccessTokenRefreshError
31 from oauth2client.client import OAuth2WebServerFlow
32 from oauth2client.file import Storage
33 from tornado.httpserver import HTTPServer
34 from tornado.ioloop import IOLoop
36 _OAUTH_SCOPE = 'https://www.googleapis.com/auth/clouddevices'
38 _API_CLIENT_FILE = 'config.json'
39 _API_DISCOVERY_FILE = 'discovery.json'
40 _DEVICE_STATE_FILE = 'device_state.json'
42 _DEVICE_SETUP_SSID = 'GCDPrototype.camera.privet'
43 _DEVICE_NAME = 'GCD Prototype'
44 _DEVICE_TYPE = 'camera'
48 'systemName': 'LEDFlasher',
49 'deviceKind': 'vendor',
50 'displayName': 'LED Flasher',
52 'supportedType': 'xmpp'
67 wpa_supplicant_cmd = 'wpa_supplicant -Dwext -iwlan0 -cwpa_supplicant.conf'
68 ifconfig_cmd = 'ifconfig wlan0 192.168.0.3'
69 hostapd_cmd = 'hostapd hostapd-min.conf'
70 dhclient_release = 'dhclient -r wlan0'
71 dhclient_renew = 'dhclient wlan0'
72 dhcpd_cmd = 'udhcpd -f /etc/udhcpd.conf'
74 wpa_supplicant_conf = 'wpa_supplicant.conf'
76 wpa_supplicant_template = """
87 led_path = '/sys/class/leds/ath9k_htc-phy0/'
90 class DeviceUnregisteredError(Exception):
94 def ignore_errors(func):
95 def inner(*args, **kwargs):
98 except Exception: # pylint: disable=broad-except
99 print 'Got error in unsafe function:'
100 traceback.print_exc()
104 class CommandWrapperReal(object):
105 """Command wrapper that executs shell commands."""
107 def __init__(self, cmd):
111 self.cmd_str = ' '.join(cmd)
115 print 'Start: ', self.cmd_str
118 self.process = subprocess.Popen(self.cmd)
121 print 'Wait: ', self.cmd_str
125 print 'End: ', self.cmd_str
127 self.process.terminate()
130 class CommandWrapperFake(object):
131 """Command wrapper that just prints shell commands."""
133 def __init__(self, cmd):
134 self.cmd_str = ' '.join(cmd)
137 print 'Fake start: ', self.cmd_str
140 print 'Fake wait: ', self.cmd_str
143 print 'Fake end: ', self.cmd_str
146 class CloudCommandHandlerFake(object):
147 """Prints devices commands without execution."""
149 def __init__(self, ioloop):
152 def handle_command(self, command_name, args):
153 if command_name == 'flashLED':
156 times = int(args['times'])
157 print 'Flashing LED %d times' % times
160 class CloudCommandHandlerReal(object):
161 """Executes device commands."""
163 def __init__(self, ioloop):
166 def handle_command(self, command_name, args):
167 if command_name == 'flashLED':
170 times = int(args['times'])
171 print 'Really flashing LED %d times' % times
172 self.flash_led(times)
175 def flash_led(self, times):
176 self.set_led(times*2, True)
178 def set_led(self, times, value):
183 file_trigger = open(os.path.join(led_path, 'brightness'), 'w')
186 file_trigger.write('1')
188 file_trigger.write('0')
192 self.ioloop.add_timeout(datetime.timedelta(milliseconds=500),
193 lambda: self.set_led(times - 1, not value))
196 class WifiHandler(object):
197 """Base class for wifi handlers."""
199 class Delegate(object):
201 def on_wifi_connected(self, unused_token):
202 """Token is optional, and all delegates should support it being None."""
203 raise Exception('Unhandled condition: WiFi connected')
205 def __init__(self, ioloop, state, delegate):
208 self.delegate = delegate
211 raise Exception('Start not implemented!')
214 raise Exception('Get SSID not implemented!')
217 class WifiHandlerReal(WifiHandler):
218 """Real wifi handler.
220 Note that by using CommandWrapperFake, you can run WifiHandlerReal on fake
221 devices for testing the wifi-specific logic.
224 def __init__(self, ioloop, state, delegate):
225 super(WifiHandlerReal, self).__init__(ioloop, state, delegate)
227 self.command_wrapper = CommandWrapperReal
228 self.hostapd = self.CommandWrapper(hostapd_cmd)
229 self.wpa_supplicant = self.CommandWrapper(wpa_supplicant_cmd)
230 self.dhcpd = self.CommandWrapper(dhcpd_cmd)
233 if self.state.has_wifi():
234 self.switch_to_wifi(self.state.ssid(), self.state.password(), None)
238 def start_hostapd(self):
241 self.run_command(ifconfig_cmd)
244 def switch_to_wifi(self, ssid, passwd, token):
246 wpa_config = open(wpa_supplicant_conf, 'w')
247 wpa_config.write(wpa_supplicant_template % (ssid, passwd))
251 self.wpa_supplicant.start()
252 self.run_command(dhclient_release)
253 self.run_command(dhclient_renew)
255 self.state.set_wifi(ssid, passwd)
256 self.delegate.on_wifi_connected(token)
257 except DeviceUnregisteredError:
259 self.wpa_supplicant.end()
264 self.wpa_supplicant.end()
268 return self.state.get_ssid()
270 def run_command(self, cmd):
271 wrapper = self.command_wrapper(cmd)
276 class WifiHandlerPassthrough(WifiHandler):
277 """Passthrough wifi handler."""
279 def __init__(self, ioloop, state, delegate):
280 super(WifiHandlerPassthrough, self).__init__(ioloop, state, delegate)
283 self.delegate.on_wifi_connected(None)
285 def switch_to_wifi(self, unused_ssid, unused_passwd, unused_token):
286 raise Exception('Should not be reached')
299 self.oauth_storage_ = Storage('oauth_creds')
303 self.credentials_ = None
304 self.has_credentials_ = False
305 self.has_wifi_ = False
315 """Saves device state to file."""
317 'has_credentials': self.has_credentials_,
318 'has_wifi': self.has_wifi_,
320 'password': self.password_,
321 'device_id': self.device_id_
323 statefile = open(_DEVICE_STATE_FILE, 'w')
324 json.dump(json_obj, statefile)
327 if self.has_credentials_:
328 self.oauth_storage_.put(self.credentials_)
331 if os.path.exists(_DEVICE_STATE_FILE):
332 statefile = open(_DEVICE_STATE_FILE, 'r')
333 json_obj = json.load(statefile)
336 self.has_credentials_ = json_obj['has_credentials']
337 self.has_wifi_ = json_obj['has_wifi']
338 self.ssid_ = json_obj['ssid']
339 self.password_ = json_obj['password']
340 self.device_id_ = json_obj['device_id']
342 if self.has_credentials_:
343 self.credentials_ = self.oauth_storage_.get()
345 def set_credentials(self, credentials, device_id):
346 self.device_id_ = device_id
347 self.credentials_ = credentials
348 self.has_credentials_ = True
351 def set_wifi(self, ssid, password):
353 self.password_ = password
354 self.has_wifi_ = True
358 return self.has_wifi_
360 def has_credentials(self):
361 return self.has_credentials_
363 def credentials(self):
364 return self.credentials_
370 return self.password_
373 return self.device_id_
376 class MDnsWrapper(object):
377 """Handles mDNS requests to device."""
379 def __init__(self, command_wrapper):
380 self.command_wrapper = command_wrapper
381 self.avahi_wrapper = None
382 self.setup_name = None
390 def get_command(self):
391 """Return the command to run mDNS daemon."""
394 '-s', '--subtype=_%s._sub._privet._tcp' % _DEVICE_TYPE,
395 _DEVICE_NAME, '_privet._tcp', '%s' % _DEVICE_PORT,
397 'type=%s' % _DEVICE_TYPE,
398 'ty=%s' % _DEVICE_NAME,
399 'id=%s' % self.device_id
402 cmd.append('setup_ssid=' + self.setup_name)
405 def run_command(self):
406 if self.avahi_wrapper:
407 self.avahi_wrapper.end()
408 self.avahi_wrapper.wait()
410 self.avahi_wrapper = self.command_wrapper(self.get_command())
411 self.avahi_wrapper.start()
413 def set_id(self, device_id):
414 self.device_id = device_id
418 def set_setup_name(self, setup_name):
419 self.setup_name = setup_name
424 class CloudDevice(object):
425 """Handles device registration and commands."""
427 class Delegate(object):
429 def on_device_started(self):
430 raise Exception('Not implemented: Device started')
432 def on_device_stopped(self):
433 raise Exception('Not implemented: Device stopped')
435 def __init__(self, ioloop, state, command_wrapper, delegate):
437 self.http = httplib2.Http()
438 if not os.path.isfile(_API_CLIENT_FILE):
440 'oauth_client_id': '',
444 credentials_f = open(_API_CLIENT_FILE + '.samlpe', 'w')
445 credentials_f.write(json.dumps(credentials, sort_keys=True,
446 indent=2, separators=(',', ': ')))
447 credentials_f.close()
448 raise Exception('Missing ' + _API_CLIENT_FILE)
450 credentials_f = open(_API_CLIENT_FILE)
451 credentials = json.load(credentials_f)
452 credentials_f.close()
454 self.oauth_client_id = credentials['oauth_client_id']
455 self.oauth_secret = credentials['oauth_secret']
456 self.api_key = credentials['api_key']
458 if not os.path.isfile(_API_DISCOVERY_FILE):
459 raise Exception('Download https://developers.google.com/'
460 'cloud-devices/v1/discovery.json')
462 f = open(_API_DISCOVERY_FILE)
465 self.gcd = build_from_document(discovery, developerKey=self.api_key,
470 self.device_id = None
471 self.credentials = None
472 self.delegate = delegate
473 self.command_handler = command_wrapper(ioloop)
475 def try_start(self, token):
476 """Tries start or register device."""
477 if self.state.has_credentials():
478 self.credentials = self.state.credentials()
479 self.device_id = self.state.device_id()
484 print 'Device not registered and has no credentials.'
485 print 'Waiting for registration.'
487 def register(self, token):
488 """Register device."""
490 'deviceDraft': DEVICE_DRAFT,
491 'oauthClientId': self.oauth_client_id
494 self.gcd.registrationTickets().patch(registrationTicketId=token,
495 body=resource).execute()
497 final_ticket = self.gcd.registrationTickets().finalize(
498 registrationTicketId=token).execute()
500 authorization_code = final_ticket['robotAccountAuthorizationCode']
501 flow = OAuth2WebServerFlow(self.oauth_client_id, self.oauth_secret,
502 _OAUTH_SCOPE, redirect_uri='oob')
503 self.credentials = flow.step2_exchange(authorization_code)
504 self.device_id = final_ticket['deviceDraft']['id']
505 self.state.set_credentials(self.credentials, self.device_id)
506 print 'Registered with device_id ', self.device_id
510 def run_device(self):
512 self.credentials.authorize(self.http)
515 self.gcd.devices().get(deviceId=self.device_id).execute()
517 # Pretty good indication the device was deleted
518 if e.resp.status == 404:
519 raise DeviceUnregisteredError()
520 except AccessTokenRefreshError:
521 raise DeviceUnregisteredError()
523 self.check_commands()
524 self.delegate.on_device_started()
526 def check_commands(self):
527 """Checks device commands."""
530 print 'Checking commands...'
531 commands = self.gcd.commands().list(deviceId=self.device_id,
532 state='queued').execute()
534 if 'commands' in commands:
535 print 'Found ', len(commands['commands']), ' commands'
536 vendor_command_name = None
538 for command in commands['commands']:
540 if command['name'].startswith('base._'):
541 vendor_command_name = command['name'][len('base._'):]
542 if 'parameters' in command:
543 parameters = command['parameters']
547 vendor_command_name = None
549 print 'Could not parse vendor command ',
551 vendor_command_name = None
553 if vendor_command_name:
554 self.command_handler.handle_command(vendor_command_name, parameters)
556 self.gcd.commands().patch(commandId=command['id'],
557 body={'state': 'done'}).execute()
559 print 'Found no commands'
561 self.ioloop.add_timeout(datetime.timedelta(milliseconds=1000),
567 def get_device_id(self):
568 return self.device_id
572 def inner(self, request, response_func, *args):
573 if request.method != 'GET':
575 return f(self, request, response_func, *args)
580 def inner(self, request, response_func, *args):
581 # if request.method != 'POST':
583 return f(self, request, response_func, *args)
587 def wifi_provisioning(f):
588 def inner(self, request, response_func, *args):
591 return f(self, request, response_func, *args)
595 def post_provisioning(f):
596 def inner(self, request, response_func, *args):
599 return f(self, request, response_func, *args)
603 class WebRequestHandler(WifiHandler.Delegate, CloudDevice.Delegate):
604 """Handles HTTP requests."""
606 class InvalidStepError(Exception):
609 class InvalidPackageError(Exception):
612 class EncryptionError(Exception):
615 class CancelableClosure(object):
616 """Allows to cancel callbacks."""
618 def __init__(self, function):
619 self.function = function
629 class DummySession(object):
630 """Handles sessions."""
632 def __init__(self, session_id):
633 self.session_id = session_id
636 def do_step(self, step, package):
638 raise self.InvalidStepError()
642 def decrypt(self, cyphertext):
643 return json.loads(cyphertext[len(self.key):])
645 def encrypt(self, plain_data):
646 return self.key + json.dumps(plain_data)
648 def get_session_id(self):
649 return self.session_id
654 def get_status(self):
657 class EmptySession(object):
658 """Handles sessions."""
660 def __init__(self, session_id):
661 self.session_id = session_id
664 def do_step(self, step, package):
665 if step != 0 or package != '':
666 raise self.InvalidStepError()
669 def decrypt(self, cyphertext):
670 return json.loads(cyphertext)
672 def encrypt(self, plain_data):
673 return json.dumps(plain_data)
675 def get_session_id(self):
676 return self.session_id
681 def get_status(self):
684 def __init__(self, ioloop, state):
685 if os.path.exists('on_real_device'):
686 mdns_wrappers = CommandWrapperReal
687 cloud_wrapper = CloudCommandHandlerReal
688 wifi_handler = WifiHandlerReal
691 mdns_wrappers = CommandWrapperReal
692 cloud_wrapper = CloudCommandHandlerFake
693 wifi_handler = WifiHandlerPassthrough
696 self.cloud_device = CloudDevice(ioloop, state, cloud_wrapper, self)
697 self.wifi_handler = wifi_handler(ioloop, state, self)
698 self.mdns_wrapper = MDnsWrapper(mdns_wrappers)
700 self.registered = False
701 self.in_session = False
704 '/internal/ping': self.do_ping,
705 '/privet/info': self.do_info,
706 '/deprecated/wifi/switch': self.do_wifi_switch,
707 '/privet/v3/session/handshake': self.do_session_handshake,
708 '/privet/v3/session/cancel': self.do_session_cancel,
709 '/privet/v3/session/call': self.do_session_call,
710 '/privet/v3/setup/start':
711 self.get_insecure_api_handler(self.do_secure_setup_start),
712 '/privet/v3/setup/cancel':
713 self.get_insecure_api_handler(self.do_secure_setup_cancel),
714 '/privet/v3/setup/status':
715 self.get_insecure_api_handler(self.do_secure_status),
718 self.current_session = None
719 self.session_cancel_callback = None
720 self.session_handlers = {
721 'dummy': self.DummySession,
722 'empty': self.EmptySession
725 self.secure_handlers = {
726 '/privet/v3/setup/start': self.do_secure_setup_start,
727 '/privet/v3/setup/cancel': self.do_secure_setup_cancel,
728 '/privet/v3/setup/status': self.do_secure_status
733 print 'Skipping device setup'
737 file_trigger = open(os.path.join(led_path, 'trigger'), 'w')
738 file_trigger.write('none')
742 self.wifi_handler.start()
743 self.mdns_wrapper.set_setup_name(_DEVICE_SETUP_SSID)
744 self.mdns_wrapper.start()
747 def do_ping(self, unused_request, response_func):
748 response_func(200, {'pong': True})
752 def do_public_info(self, unused_request, response_func):
753 info = dict(self.get_common_info().items() + {
754 'stype': self.session_handlers.keys()}.items())
755 response_func(200, info)
759 def do_info(self, unused_request, response_func):
761 'x-privet-token': 'sample',
762 'api': sorted(self.handlers.keys())
764 info = dict(self.get_common_info().items() + specific_info.items())
765 response_func(200, info)
770 def do_wifi_switch(self, request, response_func):
771 """Handles /deprecated/wifi/switch requests."""
772 data = json.loads(request.body)
775 passw = data['passw']
777 print 'Malformed content: ' + repr(data)
778 response_func(400, {'error': 'invalidParams'})
779 traceback.print_exc()
782 response_func(200, {'ssid': ssid})
783 self.wifi_handler.switch_to_wifi(ssid, passw, None)
784 # TODO(noamsml): Return to normal wifi after timeout (cancelable)
788 def do_session_handshake(self, request, response_func):
789 """Handles /privet/v3/session/handshake requests."""
791 data = json.loads(request.body)
793 stype = data['keyExchangeType']
795 package = base64.b64decode(data['package'])
796 session_id = data['sessionID']
797 except (KeyError, TypeError):
798 traceback.print_exc()
799 print 'Malformed content: ' + repr(data)
800 response_func(400, {'error': 'invalidParams'})
803 if self.current_session:
804 if session_id != self.current_session.get_session_id():
805 response_func(400, {'error': 'maxSessionsExceeded'})
807 if stype != self.current_session.get_stype():
808 response_func(400, {'error': 'unsupportedKeyExchangeType'})
811 if stype not in self.session_handlers:
812 response_func(400, {'error': 'unsupportedKeyExchangeType'})
814 self.current_session = self.session_handlers[stype](session_id)
817 output_package = self.current_session.do_step(step, package)
818 except self.InvalidStepError:
819 response_func(400, {'error': 'invalidStep'})
821 except self.InvalidPackageError:
822 response_func(400, {'error': 'invalidPackage'})
826 'status': self.current_session.get_status(),
828 'package': base64.b64encode(output_package),
829 'sessionID': session_id
831 response_func(200, return_obj)
832 self.post_session_cancel()
836 def do_session_cancel(self, request, response_func):
837 """Handles /privet/v3/session/cancel requests."""
838 data = json.loads(request.body)
840 session_id = data['sessionID']
842 response_func(400, {'error': 'invalidParams'})
845 if self.current_session and session_id == self.current_session.session_id:
846 self.current_session = None
847 if self.session_cancel_callback:
848 self.session_cancel_callback.cancel()
849 response_func(200, {'status': 'cancelled', 'sessionID': session_id})
851 response_func(400, {'error': 'unknownSession'})
855 def do_session_call(self, request, response_func):
856 """Handles /privet/v3/session/call requests."""
858 session_id = request.headers['X-Privet-SessionID']
860 response_func(400, {'error': 'unknownSession'})
863 if (not self.current_session or
864 session_id != self.current_session.session_id):
865 response_func(400, {'error': 'unknownSession'})
869 decrypted = self.current_session.decrypt(request.body)
870 except self.EncryptionError:
871 response_func(400, {'error': 'encryptionError'})
874 def encrypted_response_func(code, data):
876 self.encrypted_send_response(request, code, dict(data.items() + {
877 'api': decrypted['api']
880 self.encrypted_send_response(request, code, {
881 'api': decrypted['api'],
885 if ('api' not in decrypted or 'input' not in decrypted or
886 type(decrypted['input']) != dict):
887 print 'Invalid params in API stage'
888 encrypted_response_func(200, {'error': 'invalidParams'})
891 if decrypted['api'] in self.secure_handlers:
892 self.secure_handlers[decrypted['api']](request,
893 encrypted_response_func,
896 encrypted_response_func(200, {'error': 'unknownApi'})
898 self.post_session_cancel()
901 def get_insecure_api_handler(self, handler):
902 def inner(request, func):
903 return self.insecure_api_handler(request, func, handler)
907 def insecure_api_handler(self, request, response_func, handler):
908 real_params = json.loads(request.body) if request.body else {}
909 handler(request, response_func, real_params)
912 def do_secure_status(self, unused_request, response_func, unused_params):
913 """Handles /privet/v3/setup/status requests."""
923 setup['wifi']['status'] = 'complete'
924 setup['wifi']['ssid'] = '' # TODO(noamsml): Add SSID to status
926 setup['wifi']['status'] = 'available'
928 if self.cloud_device.get_device_id():
929 setup['registration']['status'] = 'complete'
930 setup['registration']['id'] = self.cloud_device.get_device_id()
932 setup['registration']['status'] = 'available'
933 response_func(200, setup)
935 def do_secure_setup_start(self, unused_request, response_func, params):
936 """Handles /privet/v3/setup/start requests."""
943 ssid = params['wifi']['ssid']
944 passw = params['wifi']['passphrase']
946 if 'registration' in params:
947 token = params['registration']['ticketID']
949 print 'Invalid params in bootstrap stage'
950 response_func(400, {'error': 'invalidParams'})
955 self.wifi_handler.switch_to_wifi(ssid, passw, token)
957 self.cloud_device.register(token)
959 response_func(400, {'error': 'invalidParams'})
962 pass # TODO(noamsml): store error message in this case
964 self.do_secure_status(unused_request, response_func, params)
966 def do_secure_setup_cancel(self, request, response_func, params):
969 def handle_request(self, request):
970 def response_func(code, data):
971 self.real_send_response(request, code, data)
974 print '[INFO] %s %s' % (request.method, request.path)
975 if request.path in self.handlers:
976 handled = self.handlers[request.path](request, response_func)
979 self.real_send_response(request, 404, {'error': 'notFound'})
981 def encrypted_send_response(self, request, code, data):
982 self.raw_send_response(request, code,
983 self.current_session.encrypt(data))
985 def real_send_response(self, request, code, data):
986 data = json.dumps(data, sort_keys=True, indent=2, separators=(',', ': '))
988 self.raw_send_response(request, code, data)
990 def raw_send_response(self, request, code, data):
991 request.write('HTTP/1.1 %d Maybe OK\n' % code)
992 request.write('Content-Type: application/json\n')
993 request.write('Content-Length: %s\n\n' % len(data))
997 def device_state(self):
1000 def get_common_info(self):
1003 'name': 'Sample Device',
1004 'device_state': self.device_state()
1007 def post_session_cancel(self):
1008 if self.session_cancel_callback:
1009 self.session_cancel_callback.cancel()
1010 self.session_cancel_callback = self.CancelableClosure(self.session_cancel)
1011 self.ioloop.add_timeout(datetime.timedelta(minutes=2),
1012 self.session_cancel_callback)
1014 def session_cancel(self):
1015 self.current_session = None
1017 # WifiHandler.Delegate implementation
1018 def on_wifi_connected(self, token):
1019 self.mdns_wrapper.set_setup_name(None)
1020 self.cloud_device.try_start(token)
1023 def on_device_started(self):
1024 self.mdns_wrapper.set_id(self.cloud_device.get_device_id())
1026 def on_device_stopped(self):
1030 self.wifi_handler.stop()
1031 self.cloud_device.stop()
1038 ioloop = IOLoop.instance()
1040 handler = WebRequestHandler(ioloop, state)
1044 atexit.register(logic_stop)
1045 server = HTTPServer(handler.handle_request)
1046 server.listen(_DEVICE_PORT)
1050 if __name__ == '__main__':