├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── leapcast ├── __init__.py ├── __main__.py ├── apps │ ├── __init__.py │ └── default.py ├── environment.py ├── services │ ├── __init__.py │ ├── dial.py │ ├── leap.py │ ├── leap_factory.py │ ├── ssdp.py │ └── websocket.py └── utils.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | .idea** -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | install: pip install flake8 -r requirements.txt 5 | script: 6 | - make test 7 | deploy: 8 | provider: pypi 9 | user: janez.troha 10 | password: 11 | secure: efknTHUuHpc4UPcy2u90pBWuEFMiX1I209wa5ZQbr1A0B9VPcwTxi4kvnfqqOV/PBGT58Hkx1/hltZ/ORBufEWUKQWzBxtPXdPfcHVXu4wdWrZycLXt6mKhynPW/qDKlPEUv3yUTPFlH8e2kp3eSyuWI4K/yaZ050f2hvjzX78Y= 12 | on: 13 | tags: true 14 | repo: dz0ny/leapcast 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 dz0ny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include leapcast * 2 | include * 3 | global-exclude *.pyc 4 | 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "test - run tests quickly with the default Python" 7 | @echo "release - package and upload a release" 8 | @echo "sdist - package" 9 | 10 | clean: clean-build clean-pyc 11 | 12 | clean-build: 13 | rm -fr build/ 14 | rm -fr dist/ 15 | rm -fr *.egg-info 16 | 17 | clean-pyc: 18 | find . -name '*.pyc' -exec rm -f {} + 19 | find . -name '*.pyo' -exec rm -f {} + 20 | find . -name '*~' -exec rm -f {} + 21 | 22 | test: 23 | flake8 leapcast --ignore=E501,F403 24 | 25 | release: clean 26 | pandoc --from=markdown --to=rst --output=README.rst README.md 27 | python setup.py sdist upload 28 | 29 | sdist: clean 30 | python setup.py sdist 31 | ls -l dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # leapcast (deprecated) 2 | 3 | This project no longer works because Google locked down entire API. There are alternatives like cloning device, casting apk from nexus player etc. Thanks for all contibutors and I hope we all learned something from this project. Meanwhile I'am keeping https://github.com/dz0ny/leapcast/issues/130 open if anyone wants to discuss something related to lepacast or 2nd screen paradigm. 4 | 5 | I would love to re-implement this as an open source alternative to chromecast someday, but I won't make any promises. 6 | 7 | Have fun :) 8 | 9 | ## Authors 10 | 11 | The following persons have contributed to leapcast. 12 | 13 | - Janez Troha 14 | - Tyler Hall 15 | - Edward Shaw 16 | - Jan Henrik 17 | - Martin Polden 18 | - Thomas Taschauer 19 | - Zenobius Jiricek 20 | - Ernes Durakovic 21 | - Peter Sanford 22 | - Michel Tu 23 | - Kaiwen Xu 24 | - Norman Rasmussen 25 | - Sven Wischnowsky 26 | 27 | and others. 28 | 29 | -------------------------------------------------------------------------------- /leapcast/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import sys 4 | 5 | if not (2, 7) <= sys.version_info < (3,): 6 | sys.exit( 7 | 'Leapcast requires Python >= 2.7, < 3, but found %s' % 8 | '.'.join(map(str, sys.version_info[:3]))) 9 | 10 | __version__ = '0.1.3' 11 | __url__ = 'https://github.com/dz0ny/leapcast' 12 | __author__ = 'Janez Troha' 13 | __email__ = 'dz0ny@ubuntu.si' 14 | -------------------------------------------------------------------------------- /leapcast/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf8 -*- 3 | 4 | from __future__ import unicode_literals 5 | 6 | import signal 7 | import logging 8 | import sys 9 | from os import environ 10 | 11 | 12 | from leapcast.environment import parse_cmd, Environment 13 | from leapcast.services.leap import LEAPserver 14 | from leapcast.services.ssdp import SSDPserver 15 | 16 | logger = logging.getLogger('Leapcast') 17 | 18 | 19 | def main(): 20 | parse_cmd() 21 | 22 | if sys.platform == 'darwin' and environ.get('TMUX') is not None: 23 | logger.error('Running Chrome inside tmux on OS X might cause problems.' 24 | ' Please start leapcast outside tmux.') 25 | sys.exit(1) 26 | 27 | def shutdown(signum, frame): 28 | ssdp_server.shutdown() 29 | leap_server.sig_handler(signum, frame) 30 | 31 | signal.signal(signal.SIGTERM, shutdown) 32 | signal.signal(signal.SIGINT, shutdown) 33 | 34 | ssdp_server = SSDPserver() 35 | ssdp_server.start(Environment.interfaces) 36 | 37 | leap_server = LEAPserver() 38 | leap_server.start() 39 | 40 | if __name__ == "__main__": 41 | main() 42 | -------------------------------------------------------------------------------- /leapcast/apps/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /leapcast/apps/default.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from leapcast.services.leap_factory import LEAPfactory 4 | 5 | 6 | class ChromeCast(LEAPfactory): 7 | url = "https://www.gstatic.com/cv/receiver.html?{{ query }}" 8 | 9 | 10 | class YouTube(LEAPfactory): 11 | url = "https://www.youtube.com/tv?{{ query }}" 12 | 13 | 14 | class PlayMovies(LEAPfactory): 15 | url = "https://play.google.com/video/avi/eureka?{{ query }}" 16 | supported_protocols = ['play-movies', 'ramp'] 17 | 18 | 19 | class GoogleMusic(LEAPfactory): 20 | url = "https://play.google.com/music/cast/player" 21 | 22 | 23 | class GoogleCastSampleApp(LEAPfactory): 24 | url = "http://anzymrcvr.appspot.com/receiver/anzymrcvr.html" 25 | 26 | 27 | class GoogleCastPlayer(LEAPfactory): 28 | url = "https://www.gstatic.com/eureka/html/gcp.html" 29 | 30 | 31 | class Fling(LEAPfactory): 32 | url = "{{ query }}" 33 | 34 | 35 | class Pandora_App(LEAPfactory): 36 | url = "https://tv.pandora.com/cast?{{ query }}" 37 | 38 | 39 | class TicTacToe(LEAPfactory): 40 | url = "http://www.gstatic.com/eureka/sample/tictactoe/tictactoe.html" 41 | supported_protocols = ['com.google.chromecast.demo.tictactoe'] 42 | -------------------------------------------------------------------------------- /leapcast/environment.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import argparse 3 | import glob 4 | import logging 5 | import os 6 | import sys 7 | import uuid 8 | 9 | logger = logging.getLogger('Environment') 10 | 11 | 12 | def _get_chrome_path(): 13 | if sys.platform == 'win32': 14 | # First path includes fallback for Windows XP, because it doesn't have 15 | # LOCALAPPDATA variable. 16 | globs = [os.path.join( 17 | os.getenv( 18 | 'LOCALAPPDATA', os.path.join(os.getenv('USERPROFILE'), 'Local Settings\\Application Data')), 'Google\\Chrome\\Application\\chrome.exe'), 19 | os.path.join(os.getenv('ProgramW6432', 'C:\\Program Files'), 20 | 'Google\\Chrome\\Application\\chrome.exe'), 21 | os.path.join(os.getenv('ProgramFiles(x86)', 'C:\\Program Files (x86)'), 'Google\\Chrome\\Application\\chrome.exe')] 22 | elif sys.platform == 'darwin': 23 | globs = [ 24 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'] 25 | else: 26 | globs = ['/usr/bin/google-chrome', 27 | '/opt/google/chrome/google-chrome', 28 | '/opt/google/chrome-*/google-chrome', 29 | '/usr/bin/chromium-browser', 30 | '/usr/bin/chromium'] 31 | for g in globs: 32 | for path in glob.glob(g): 33 | if os.path.exists(path): 34 | return path 35 | 36 | 37 | class Environment(object): 38 | channels = dict() 39 | global_status = dict() 40 | friendlyName = 'leapcast' 41 | user_agent = 'Mozilla/5.0 (CrKey - 0.9.3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1573.2 Safari/537.36' 42 | chrome = _get_chrome_path() 43 | fullscreen = False 44 | window_size = False 45 | interfaces = None 46 | uuid = None 47 | ips = [] 48 | apps = None 49 | verbosity = logging.INFO 50 | 51 | 52 | def parse_cmd(): 53 | parser = argparse.ArgumentParser() 54 | parser.add_argument('-d', '--debug', action='store_true', 55 | default=False, dest='debug', help='Debug') 56 | parser.add_argument('-i', '--interface', action='append', 57 | dest='interfaces', 58 | help='Interface to bind to (can be specified multiple times)', 59 | metavar='IPADDRESS') 60 | parser.add_argument('--name', help='Friendly name for this device') 61 | parser.add_argument('--user_agent', help='Custom user agent') 62 | parser.add_argument('--chrome', help='Path to Google Chrome executable') 63 | parser.add_argument('--fullscreen', action='store_true', 64 | default=False, help='Start in full-screen mode') 65 | parser.add_argument('--window_size', 66 | default=False, 67 | help='Set the initial chrome window size. eg 1920,1080') 68 | parser.add_argument( 69 | '--ips', help='Allowed ips from which clients can connect') 70 | parser.add_argument('--apps', help='Add apps from JSON file') 71 | 72 | args = parser.parse_args() 73 | 74 | if args.debug: 75 | Environment.verbosity = logging.DEBUG 76 | logging.basicConfig(level=Environment.verbosity) 77 | 78 | if args.interfaces: 79 | Environment.interfaces = args.interfaces 80 | logger.debug('Interfaces is %s' % Environment.interfaces) 81 | 82 | if args.name: 83 | Environment.friendlyName = args.name 84 | logger.debug('Service name is %s' % Environment.friendlyName) 85 | 86 | if args.user_agent: 87 | Environment.user_agent = args.user_agent 88 | logger.debug('User agent is %s' % args.user_agent) 89 | 90 | if args.chrome: 91 | Environment.chrome = args.chrome 92 | logger.debug('Chrome path is %s' % args.chrome) 93 | 94 | if args.fullscreen: 95 | Environment.fullscreen = True 96 | 97 | if args.window_size: 98 | Environment.window_size = args.window_size 99 | 100 | if args.ips: 101 | Environment.ips = args.ips 102 | 103 | if args.apps: 104 | Environment.apps = args.apps 105 | 106 | if Environment.chrome is None: 107 | parser.error('could not locate chrome; use --chrome to specify one') 108 | 109 | generate_uuid() 110 | 111 | 112 | def generate_uuid(): 113 | Environment.uuid = str(uuid.uuid5( 114 | uuid.NAMESPACE_DNS, ('device.leapcast.%s' % 115 | Environment.friendlyName).encode('utf8'))) 116 | -------------------------------------------------------------------------------- /leapcast/services/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | -------------------------------------------------------------------------------- /leapcast/services/dial.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import leapcast 4 | from leapcast.environment import Environment 5 | from leapcast.services.websocket import App 6 | from leapcast.utils import render 7 | import tornado.web 8 | 9 | 10 | class DeviceHandler(tornado.web.RequestHandler): 11 | 12 | ''' 13 | Holds info about device 14 | ''' 15 | 16 | device = ''' 17 | 18 | 19 | 1 20 | 0 21 | 22 | {{ path }} 23 | 24 | urn:schemas-upnp-org:device:dail:1 25 | {{ friendlyName }} 26 | Google Inc. 27 | Eureka Dongle 28 | uuid:{{ uuid }} 29 | 30 | 31 | urn:schemas-upnp-org:service:dail:1 32 | urn:upnp-org:serviceId:dail 33 | /ssdp/notfound 34 | /ssdp/notfound 35 | /ssdp/notfound 36 | 37 | 38 | 39 | ''' 40 | 41 | def get(self): 42 | if Environment.ips and self.request.remote_ip not in Environment.ips: 43 | raise tornado.web.HTTPError(403) 44 | 45 | if self.request.uri == "/apps": 46 | for app, astatus in Environment.global_status.items(): 47 | if astatus["state"] == "running": 48 | self.redirect("/apps/%s" % app) 49 | self.set_status(204) 50 | self.set_header( 51 | "Access-Control-Allow-Method", "GET, POST, DELETE, OPTIONS") 52 | self.set_header("Access-Control-Expose-Headers", "Location") 53 | else: 54 | self.set_header( 55 | "Access-Control-Allow-Method", "GET, POST, DELETE, OPTIONS") 56 | self.set_header("Access-Control-Expose-Headers", "Location") 57 | self.add_header( 58 | "Application-URL", "http://%s/apps" % self.request.host) 59 | self.set_header("Content-Type", "application/xml") 60 | self.write(render(self.device).generate( 61 | friendlyName=Environment.friendlyName, 62 | uuid=Environment.uuid, 63 | path="http://%s" % self.request.host) 64 | ) 65 | 66 | 67 | class SetupHandler(tornado.web.RequestHandler): 68 | 69 | ''' 70 | Holds info about device setup and status 71 | ''' 72 | 73 | status = '''{ 74 | "build_version":"{{ buildVersion}}", 75 | "connected":true, 76 | "detail":{ 77 | "locale":{"display_string":"English (United States)"}, 78 | "timezone":{"display_string":"America/Los Angeles","offset":-480} 79 | }, 80 | "has_update":false, 81 | "hdmi_control":true, 82 | "hotspot_bssid":"FA:8F:CA:3A:0C:D0", 83 | "locale": "en_US", 84 | "mac_address":"00:00:00:00:00:00", 85 | "name":"{{ name }}", 86 | "noise_level":-90, 87 | "opt_in":{"crash":true,"device_id":false,"stats":true}, 88 | "public_key":"MIIBCgKCAQEAyoaWlKNT6W5+/cJXEpIfeGvogtJ1DghEUs2PmHkX3n4bByfmMRDYjuhcb97vd8N3HFe5sld6QSc+FJz7TSGp/700e6nrkbGj9abwvobey/IrLbHTPLtPy/ceUnwmAXczkhay32auKTaM5ZYjwcHZkaU9XuOQVIPpyLF1yQerFChugCpQ+bvIoJnTkoZAuV1A1Vp4qf3nn4Ll9Bi0R4HJrGNmOKUEjKP7H1aCLSqj13FgJ2s2g20CCD8307Otq8n5fR+9/c01dtKgQacupysA+4LVyk4npFn5cXlzkkNPadcKskARtb9COTP2jBWcowDwjKSBokAgi/es/5gDhZm4dwIDAQAB", 89 | "release_track":"stable-channel", 90 | "setup_state":60, 91 | {% raw signData %} 92 | "signal_level":-50, 93 | "ssdp_udn":"82c5cb87-27b4-2a9a-d4e1-5811f2b1992c", 94 | "ssid":"{{ friendlyName }}", 95 | "timezone":"America/Los_Angeles", 96 | "uptime":0.0, 97 | "version":4, 98 | "wpa_configured":true, 99 | "wpa_state":10 100 | }''' 101 | 102 | # Chromium OS's network_DestinationVerification.py has a verify test that 103 | # shows that it is possible to verify signed_data by: 104 | # echo "" | base64 -d | openssl rsautl -verify -inkey -certin -asn1parse 105 | # The signed string should match: 106 | # echo -n ",,,," | openssl sha1 -binary | hd 107 | 108 | sign_data = ''' 109 | "sign": { 110 | "certificate":"-----BEGIN CERTIFICATE-----\\nMIIDqzCCApOgAwIBAgIEUf6McjANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJV\\nUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzET\\nMBEGA1UECgwKR29vZ2xlIEluYzESMBAGA1UECwwJR29vZ2xlIFRWMRgwFgYDVQQD\\nDA9FdXJla2EgR2VuMSBJQ0EwHhcNMTMwODA0MTcxNjM0WhcNMzMwNzMwMTcxNjM0\\nWjCBgDETMBEGA1UEChMKR29vZ2xlIEluYzETMBEGA1UECBMKQ2FsaWZvcm5pYTEL\\nMAkGA1UEBhMCVVMxFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEjAQBgNVBAsTCUdv\\nb2dsZSBUVjEbMBkGA1UEAxMSWktCVjIgRkE4RkNBM0EwQ0QwMIIBIjANBgkqhkiG\\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+HGhzj+XEwhUT7W4FbaR8M2sNxCF0VrlWsw6\\nSkFHOINt6t+4B11Q7TSfz1yzrMhUSQvaE2gP2F/h3LD03rCnnE4avonZYTBr/U/E\\nJZYDjEtOClFmBmqNf6ZEE8bxF/nsit1e5XicO0OJHSmRlvibbrmC2rnFwj/cEDpm\\na1hdqpRQkeG0ceb9qbvvpxBq4MBsomzzbSq2nl7dQFBpxDd2jm7g+4EC7KqWmkWt\\n3XgX++0qk4qFlbc/+ySqheYYddU0eeExvg93WkTRr5m6ZuaTQn7LOO9IiR8PwSnz\\nxQmuirtAc50089T1oyV7ANZlNtj2oW2XjKUvxA3n+x8jCqAwfwIDAQABoy8wLTAJ\\nBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjANBgkq\\nhkiG9w0BAQUFAAOCAQEAXmXinb7zoutrwCw+3SQVGbQycnMpWz1hDRS+BZ04QCm0\\naxneva74Snptjasnw3eEW9Yb1N8diLlrUWkT5Q9l8AA/W4R3B873lPTzWhobXFCq\\nIhrkTDBSOtx/bQj7Sy/npmoj6glYlKKANqTCDhjHvykOLrUUeCjltYAy3i8km+4b\\nTxjfB6D3IIGB3kvb1L4TmvgSuXDhkz0qx2qR/bM6vGShnNRCQo6oR4hAMFlvDlXR\\nhRfuibIJwtbA2cLUyG/UjgQwJEPrlOT6ZyLRHWMjiROKcqv3kqatBfNyIjkVD4uH\\nc+WK9DlJnI9bLy46qYRVbzhhDJUkfZVtDKiUbvz3bg==\\n-----END CERTIFICATE-----\\n", 111 | "nonce": "Aw4o0/sbVr537Kdrw9YotiXxCLIaiRrDkHeHrOpih3U=", 112 | "signed_data": "fcTwn3K4I/ccok1MeZ5/nkM0pI5v4SrTv3Q4ppOQtVL5ii3qitNo+NLhY+DM9zmnP6ndNMZbkyIEyMm7LjganoDoE+o0e0/r4TyGEGLxYlfWSzf+Z3cSdNe4+MyHx/7z02E0/3lLsOFuOEPSgR26JFtyhDLCJ9Y8Cpl3GZMUqm4toaTNaIbhNMR9Bwjkz4ozKXzFl9dF5FTU6N48KeUP/3CuYqgm04BVUGxg+DbBmTidRnZE4eGdt9ICJht9ArUNQDL2UdRYVY2sfgLmF29exTaSrVkBZb/MsbDxN5nYpF1uE7IhzJnT5yFM9pmUOIKKTfeVaLVLGgoWd+pjEbOv+Q==" 113 | }, 114 | ''' 115 | 116 | timezones = '''[ 117 | {"timezone":"America/Los_Angeles","display_string":"America/Los Angeles","offset":-480} 118 | ]''' 119 | locales = '''[ 120 | {"locale":"en_US","display_string":"English (United States)"} 121 | ]''' 122 | wifi_networks = '''[ 123 | {"bssid":"00:00:00:00:00:00","signal_level":-60,"ssid":"leapcast","wpa_auth":7,"wpa_cipher":4} 124 | ]''' 125 | 126 | def get(self, module=None): 127 | if Environment.ips and self.request.remote_ip not in Environment.ips: 128 | raise tornado.web.HTTPError(403) 129 | 130 | if module == "eureka_info": 131 | self.set_header( 132 | "Access-Control-Allow-Headers", "Content-Type") 133 | self.set_header( 134 | "Access-Control-Allow-Origin", "https://cast.google.com") 135 | self.set_header("Content-Type", "application/json") 136 | if 'sign' in self.request.query: 137 | name = 'Chromecast8991' 138 | signData = self.sign_data 139 | else: 140 | name = Environment.friendlyName 141 | signData = '' 142 | self.write(render(self.status).generate( 143 | name=name, 144 | friendlyName=Environment.friendlyName, 145 | buildVersion='leapcast %s' % leapcast.__version__, 146 | signData=signData, 147 | uuid=Environment.uuid) 148 | ) 149 | elif module == "supported_timezones": 150 | self.set_header("Content-Type", "application/json") 151 | self.write(self.timezones) 152 | elif module == "supported_locales": 153 | self.set_header("Content-Type", "application/json") 154 | self.write(self.locales) 155 | elif module == "scan_results": 156 | self.set_header("Content-Type", "application/json") 157 | self.write(self.wifi_networks) 158 | else: 159 | raise tornado.web.HTTPError(404) 160 | 161 | def post(self, module=None): 162 | if ((len(Environment.ips) == 0) | (self.request.remote_ip in Environment.ips)): 163 | if module == "scan_wifi": 164 | pass 165 | elif module == "set_eureka_info": 166 | pass 167 | elif module == "connect_wifi": 168 | pass 169 | else: 170 | raise tornado.web.HTTPError(404) 171 | else: 172 | raise tornado.web.HTTPError(404) 173 | 174 | 175 | class ChannelFactory(tornado.web.RequestHandler): 176 | 177 | ''' 178 | Creates Websocket Channel. This is requested by 2nd screen application 179 | ''' 180 | @tornado.web.asynchronous 181 | def post(self, app=None): 182 | self.app = App.get_instance(app) 183 | self.set_header( 184 | "Access-Control-Allow-Method", "POST, OPTIONS") 185 | self.set_header("Access-Control-Allow-Headers", "Content-Type") 186 | self.set_header("Content-Type", "application/json") 187 | self.finish( 188 | '{"URL":"ws://%s/session/%s?%s","pingInterval":3}' % ( 189 | self.request.host, app, self.app.get_apps_count()) 190 | ) 191 | self.app.create_application_channel(self.request.body) 192 | -------------------------------------------------------------------------------- /leapcast/services/leap.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import tornado.ioloop 4 | import tornado.web 5 | import tornado.websocket 6 | import logging 7 | import json 8 | import requests 9 | from leapcast.apps.default import * 10 | from leapcast.services.dial import DeviceHandler, ChannelFactory, SetupHandler 11 | from leapcast.services.websocket import ServiceChannel, ReceiverChannel, ApplicationChannel, CastPlatform 12 | from leapcast.services.leap_factory import LEAPfactory 13 | from leapcast.environment import Environment 14 | 15 | 16 | class LEAPserver(object): 17 | 18 | def start(self): 19 | logging.info('Starting LEAP server') 20 | routes = [ 21 | (r"/ssdp/device-desc.xml", DeviceHandler), 22 | (r"/setup/([^\/]+)", SetupHandler), 23 | (r"/apps", DeviceHandler), 24 | (r"/connection", ServiceChannel), 25 | (r"/connection/([^\/]+)", ChannelFactory), 26 | (r"/receiver/([^\/]+)", ReceiverChannel), 27 | (r"/session/([^\/]+)", ApplicationChannel), 28 | (r"/system/control", CastPlatform), 29 | ] 30 | 31 | # download apps from google servers 32 | logging.info('Loading Config-JSON from Google-Server') 33 | app_dict_url = 'https://clients3.google.com/cast/chromecast/device/config' 34 | # load json-file 35 | resp = requests.get(url=app_dict_url) 36 | logging.info('Parsing Config-JSON') 37 | # parse json-file 38 | data = json.loads(resp.content.replace(")]}'", "")) 39 | # list of added apps for apps getting added manually 40 | added_apps = [] 41 | 42 | if Environment.apps: 43 | logging.info('Reading app file: %s' % Environment.apps) 44 | try: 45 | f = open(Environment.apps) 46 | tmp = json.load(f) 47 | f.close() 48 | 49 | for key in tmp: 50 | if key == 'applications': 51 | data[key] += tmp[key] 52 | 53 | else: 54 | data[key] = tmp[key] 55 | except Exception as e: 56 | logging.error('Couldn\'t read app file: %s' % e) 57 | 58 | for app in data['applications']: 59 | name = app['app_name'] 60 | name = name.encode('utf-8') 61 | if 'url' not in app: 62 | logging.warn('Didn\'t add %s because it has no URL!' % name) 63 | continue 64 | logging.info('Added %s app' % name) 65 | url = app['url'] 66 | url = url.replace("${{URL_ENCODED_POST_DATA}}", "{{ query }}").replace( 67 | "${POST_DATA}", "{{ query }}") 68 | # this doesn't support all params yet, but seems to work with 69 | # youtube, chromecast and play music. 70 | clazz = type((name), (LEAPfactory,), {"url": url}) 71 | routes.append(("(/apps/" + name + "|/apps/" + name + ".*)", clazz)) 72 | added_apps.append(name) 73 | 74 | # add registread apps 75 | for app in LEAPfactory.get_subclasses(): 76 | name = app.__name__ 77 | if name in added_apps: 78 | continue 79 | logging.info('Added %s app' % name) 80 | routes.append(( 81 | r"(/apps/" + name + "|/apps/" + name + ".*)", app)) 82 | 83 | self.application = tornado.web.Application(routes) 84 | self.application.listen(8008) 85 | tornado.ioloop.IOLoop.instance().start() 86 | 87 | def shutdown(self, ): 88 | logging.info('Stopping DIAL server') 89 | tornado.ioloop.IOLoop.instance().stop() 90 | 91 | def sig_handler(self, sig, frame): 92 | tornado.ioloop.IOLoop.instance().add_callback(self.shutdown) 93 | -------------------------------------------------------------------------------- /leapcast/services/leap_factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import subprocess 4 | import copy 5 | import logging 6 | import tempfile 7 | import shutil 8 | import tornado.websocket 9 | import tornado.ioloop 10 | import tornado.web 11 | from leapcast.services.websocket import App 12 | from leapcast.utils import render 13 | from leapcast.environment import Environment 14 | 15 | 16 | class Browser(object): 17 | 18 | def __init__(self, appurl): 19 | args = [ 20 | Environment.chrome, 21 | '--allow-running-insecure-content', 22 | '--no-default-browser-check', 23 | '--ignore-gpu-blacklist', 24 | '--incognito', 25 | '--no-first-run', 26 | '--kiosk', 27 | '--disable-translate', 28 | '--user-agent=%s' % Environment.user_agent.encode('utf8') 29 | ] 30 | self.tmpdir = tempfile.mkdtemp(prefix='leapcast-') 31 | args.append('--user-data-dir=%s' % self.tmpdir) 32 | if Environment.window_size: 33 | args.append('--window-size=%s' % Environment.window_size) 34 | if not Environment.fullscreen: 35 | args.append('--app=%s' % appurl.encode('utf8')) 36 | else: 37 | args.append(appurl.encode('utf8')) 38 | logging.debug(args) 39 | self.pid = subprocess.Popen(args) 40 | 41 | def destroy(self): 42 | self.pid.terminate() 43 | self.pid.wait() 44 | shutil.rmtree(self.tmpdir) 45 | 46 | def is_running(self): 47 | return self.pid.poll() is None 48 | 49 | def __bool__(self): 50 | return self.is_running() 51 | 52 | 53 | class LEAPfactory(tornado.web.RequestHandler): 54 | application_status = dict( 55 | name='', 56 | state='stopped', 57 | link='', 58 | browser=None, 59 | connectionSvcURL='', 60 | protocols='', 61 | app=None 62 | ) 63 | 64 | service = ''' 65 | 66 | {{ name }} 67 | 68 | {% if state == "running" %} 69 | 70 | {{ connectionSvcURL }} 71 | 72 | {% for x in protocols %} 73 | {{ x }} 74 | {% end %} 75 | 76 | 77 | {% end %} 78 | {{ state }} 79 | {% if state == "running" %} 80 | 81 | {{ name }} Receiver 82 | 83 | 84 | {% end %} 85 | 86 | ''' 87 | 88 | ip = None 89 | url = '{{query}}' 90 | supported_protocols = ['ramp'] 91 | 92 | @classmethod 93 | def get_subclasses(c): 94 | subclasses = c.__subclasses__() 95 | return list(subclasses) 96 | 97 | def get_name(self): 98 | return self.__class__.__name__ 99 | 100 | def get_status_dict(self): 101 | status = copy.deepcopy(self.application_status) 102 | status['name'] = self.get_name() 103 | return status 104 | 105 | def prepare(self): 106 | self.ip = self.request.host 107 | 108 | def get_app_status(self): 109 | return Environment.global_status.get(self.get_name(), self.get_status_dict()) 110 | 111 | def set_app_status(self, app_status): 112 | 113 | app_status['name'] = self.get_name() 114 | Environment.global_status[self.get_name()] = app_status 115 | 116 | def _response(self): 117 | self.set_header('Content-Type', 'application/xml') 118 | self.set_header( 119 | 'Access-Control-Allow-Method', 'GET, POST, DELETE, OPTIONS') 120 | self.set_header('Access-Control-Expose-Headers', 'Location') 121 | self.set_header('Cache-control', 'no-cache, must-revalidate, no-store') 122 | self.finish(self._toXML(self.get_app_status())) 123 | 124 | @tornado.web.asynchronous 125 | def post(self, sec): 126 | '''Start app''' 127 | self.clear() 128 | self.set_status(201) 129 | self.set_header('Location', self._getLocation(self.get_name())) 130 | status = self.get_app_status() 131 | if status['browser'] is None: 132 | status['state'] = 'running' 133 | if self.url == "https://tv.pandora.com/cast?{{ query }}": 134 | appurl = render(self.url.replace("{{ query }}", self.request.body)).generate(query=self.request.body) 135 | else: 136 | appurl = render(self.url).generate(query=self.request.body) 137 | status['browser'] = Browser(appurl) 138 | status['connectionSvcURL'] = 'http://%s/connection/%s' % ( 139 | self.ip, self.get_name()) 140 | status['protocols'] = self.supported_protocols 141 | status['app'] = App.get_instance(sec) 142 | 143 | self.set_app_status(status) 144 | self.finish() 145 | 146 | def stop_app(self): 147 | self.clear() 148 | browser = self.get_app_status()['browser'] 149 | if browser is not None: 150 | browser.destroy() 151 | else: 152 | logging.warning('App already closed in destroy()') 153 | status = self.get_status_dict() 154 | status['state'] = 'stopped' 155 | status['browser'] = None 156 | 157 | self.set_app_status(status) 158 | 159 | @tornado.web.asynchronous 160 | def get(self, sec): 161 | '''Status of an app''' 162 | self.clear() 163 | browser = self.get_app_status()['browser'] 164 | if not browser: 165 | logging.debug('App crashed or closed') 166 | # app crashed or closed 167 | status = self.get_status_dict() 168 | status['state'] = 'stopped' 169 | status['browser'] = None 170 | self.set_app_status(status) 171 | 172 | self._response() 173 | 174 | @tornado.web.asynchronous 175 | def delete(self, sec): 176 | '''Close app''' 177 | self.stop_app() 178 | self._response() 179 | 180 | def _getLocation(self, app): 181 | return 'http://%s/apps/%s/web-1' % (self.ip, app) 182 | 183 | def _toXML(self, data): 184 | return render(self.service).generate(**data) 185 | 186 | @classmethod 187 | def toInfo(cls): 188 | data = copy.deepcopy(cls.application_status) 189 | data['name'] = cls.__name__ 190 | data = Environment.global_status.get(cls.__name__, data) 191 | return render(cls.service).generate(data) 192 | -------------------------------------------------------------------------------- /leapcast/services/ssdp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | from __future__ import unicode_literals 4 | import contextlib 5 | import socket 6 | from leapcast.utils import render 7 | from leapcast.environment import Environment 8 | import struct 9 | import operator 10 | import logging 11 | from leapcast.utils import ControlMixin 12 | from SocketServer import ThreadingUDPServer, DatagramRequestHandler 13 | 14 | 15 | def GetInterfaceAddress(if_name): 16 | import fcntl # late import as this is only supported on Unix platforms. 17 | SIOCGIFADDR = 0x8915 18 | with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: 19 | return fcntl.ioctl(s.fileno(), SIOCGIFADDR, struct.pack(b'256s', if_name[:15]))[20:24] 20 | 21 | 22 | class MulticastServer(ControlMixin, ThreadingUDPServer): 23 | 24 | allow_reuse_address = True 25 | 26 | def __init__(self, addr, handler, poll_interval=0.5, bind_and_activate=True, interfaces=None): 27 | ThreadingUDPServer.__init__(self, ('', addr[1]), 28 | handler, 29 | bind_and_activate) 30 | ControlMixin.__init__(self, handler, poll_interval) 31 | self._multicast_address = addr 32 | self._listen_interfaces = interfaces 33 | self.setLoopbackMode(1) # localhost 34 | self.setTTL(2) # localhost and local network 35 | self.handle_membership(socket.IP_ADD_MEMBERSHIP) 36 | 37 | def setLoopbackMode(self, mode): 38 | mode = struct.pack("b", operator.truth(mode)) 39 | self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 40 | mode) 41 | 42 | def server_bind(self): 43 | try: 44 | if hasattr(socket, "SO_REUSEADDR"): 45 | self.socket.setsockopt( 46 | socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 47 | except Exception as e: 48 | logging.log(e) 49 | try: 50 | if hasattr(socket, "SO_REUSEPORT"): 51 | self.socket.setsockopt( 52 | socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 53 | except Exception as e: 54 | logging.log(e) 55 | ThreadingUDPServer.server_bind(self) 56 | 57 | def handle_membership(self, cmd): 58 | if self._listen_interfaces is None: 59 | mreq = struct.pack( 60 | str("4sI"), socket.inet_aton(self._multicast_address[0]), 61 | socket.INADDR_ANY) 62 | self.socket.setsockopt(socket.IPPROTO_IP, 63 | cmd, mreq) 64 | else: 65 | for interface in self._listen_interfaces: 66 | try: 67 | if_addr = socket.inet_aton(interface) 68 | except socket.error: 69 | if_addr = GetInterfaceAddress(interface) 70 | mreq = socket.inet_aton(self._multicast_address[0]) + if_addr 71 | self.socket.setsockopt(socket.IPPROTO_IP, 72 | cmd, mreq) 73 | 74 | def setTTL(self, ttl): 75 | ttl = struct.pack("B", ttl) 76 | self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) 77 | 78 | def server_close(self): 79 | self.handle_membership(socket.IP_DROP_MEMBERSHIP) 80 | 81 | 82 | class SSDPHandler(DatagramRequestHandler): 83 | 84 | header = '''\ 85 | HTTP/1.1 200 OK\r 86 | LOCATION: http://{{ ip }}:8008/ssdp/device-desc.xml\r 87 | CACHE-CONTROL: max-age=1800\r 88 | CONFIGID.UPNP.ORG: 7337\r 89 | BOOTID.UPNP.ORG: 7337\r 90 | USN: uuid:{{ uuid }}\r 91 | ST: urn:dial-multiscreen-org:service:dial:1\r 92 | \r 93 | ''' 94 | 95 | def handle(self): 96 | data = self.request[0].strip() 97 | self.datagramReceived(data, self.client_address) 98 | 99 | def reply(self, data, address): 100 | socket = self.request[1] 101 | socket.sendto(data, address) 102 | 103 | def get_remote_ip(self, address): 104 | # Create a socket to determine what address the client should 105 | # use 106 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 107 | s.connect(address) 108 | iface = s.getsockname()[0] 109 | s.close() 110 | return unicode(iface) 111 | 112 | def datagramReceived(self, datagram, address): 113 | if "urn:dial-multiscreen-org:service:dial:1" in datagram and "M-SEARCH" in datagram: 114 | data = render(self.header).generate( 115 | ip=self.get_remote_ip(address), 116 | uuid=Environment.uuid 117 | ) 118 | self.reply(data, address) 119 | 120 | 121 | class SSDPserver(object): 122 | SSDP_ADDR = '239.255.255.250' 123 | SSDP_PORT = 1900 124 | 125 | def start(self, interfaces): 126 | logging.info('Starting SSDP server') 127 | self.server = MulticastServer( 128 | (self.SSDP_ADDR, self.SSDP_PORT), SSDPHandler, interfaces=interfaces) 129 | self.server.start() 130 | 131 | def shutdown(self): 132 | logging.info('Stopping SSDP server') 133 | self.server.server_close() 134 | self.server.stop() 135 | -------------------------------------------------------------------------------- /leapcast/services/websocket.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | from __future__ import unicode_literals 4 | from collections import deque 5 | import json 6 | import logging 7 | from leapcast.environment import Environment 8 | import tornado.web 9 | import threading 10 | from __builtin__ import id 11 | 12 | 13 | class App(object): 14 | 15 | ''' 16 | Used to relay messages between app Environment.channels 17 | ''' 18 | name = "" 19 | lock = threading.Event() 20 | remotes = list() 21 | receivers = list() 22 | rec_queue = list() 23 | buf = {} # Buffers if the channel are not ready 24 | control_channel = list() 25 | senderid = False 26 | info = None 27 | 28 | @classmethod 29 | def get_instance(cls, app): 30 | 31 | if app in Environment.channels: 32 | return Environment.channels[app] 33 | else: 34 | instance = App() 35 | instance.name = app 36 | Environment.channels[app] = instance 37 | return instance 38 | 39 | def set_control_channel(self, ch): 40 | logging.info("Channel for app set to %s", ch) 41 | self.control_channel.append(ch) 42 | 43 | def get_control_channel(self): 44 | try: 45 | logging.info("Channel for app is %s", self.control_channel[-1]) 46 | return self.control_channel[-1] 47 | except Exception: 48 | return False 49 | 50 | def get_apps_count(self): 51 | return len(self.remotes) 52 | 53 | def add_remote(self, remote): 54 | self.remotes.append(remote) 55 | 56 | def add_receiver(self, receiver): 57 | self.receivers.append(receiver) 58 | if id(receiver) in self.buf: 59 | self.rec_queue.append(self.buf[id(receiver)]) 60 | else: 61 | self.rec_queue.append(deque()) 62 | 63 | def get_deque(self, instance): 64 | try: 65 | _id = self.receivers.index(instance) 66 | return self.rec_queue[_id] 67 | except Exception: 68 | if id(instance) in self.buf: 69 | return self.buf[id(instance)] 70 | else: 71 | self.buf[id(instance)] = deque() 72 | return self.buf[id(instance)] 73 | 74 | def get_app_channel(self, receiver): 75 | try: 76 | return self.remotes[self.receivers.index(receiver)] 77 | except Exception: 78 | return False 79 | 80 | def get_self_app_channel(self, app): 81 | try: 82 | if isinstance(self.remotes[self.remotes.index(app)].ws_connection, type(None)): 83 | return False 84 | return self.remotes[self.remotes.index(app)] 85 | except Exception: 86 | return False 87 | 88 | def get_recv_channel(self, app): 89 | try: 90 | """ 91 | if type(self.receivers[self.remotes.index(app)].ws_connection) != type(None): 92 | return self.receivers[self.remotes.index(app)] 93 | """ 94 | if isinstance(self.receivers[self.remotes.index(app)].ws_connection, type(None)): 95 | return False 96 | return self.receivers[self.remotes.index(app)] 97 | except Exception: 98 | return False 99 | 100 | def create_application_channel(self, data): 101 | if self.get_control_channel(): 102 | self.get_control_channel().new_request() 103 | else: 104 | CreateChannel(self.name, data, self.lock).start() 105 | 106 | def stop(self): 107 | for ws in self.remotes: 108 | try: 109 | ws.close() 110 | except Exception: 111 | pass 112 | self.remotes = list() 113 | for ws in self.receivers: 114 | try: 115 | ws.close() 116 | except Exception: 117 | pass 118 | self.receivers = list() 119 | self.control_channel.pop() 120 | app = Environment.global_status.get(self.name, False) 121 | if app: 122 | app.stop_app() 123 | self.buf = {} 124 | 125 | 126 | class CreateChannel (threading.Thread): 127 | 128 | def __init__(self, name, data, lock): 129 | threading.Thread.__init__(self) 130 | self.name = name 131 | self.data = data 132 | self.lock = lock 133 | 134 | def run(self): 135 | # self.lock.wait(30) 136 | self.lock.clear() 137 | self.lock.wait() 138 | App.get_instance( 139 | self.name).get_control_channel().new_request(self.data) 140 | 141 | 142 | class ServiceChannel(tornado.websocket.WebSocketHandler): 143 | 144 | ''' 145 | ws /connection 146 | From 1st screen app 147 | ''' 148 | buf = list() 149 | 150 | def open(self, app=None): 151 | self.app = App.get_instance(app) 152 | self.app.set_control_channel(self) 153 | while len(self.buf) > 0: 154 | self.reply(self.buf.pop()) 155 | 156 | def on_message(self, message): 157 | cmd = json.loads(message) 158 | if cmd["type"] == "REGISTER": 159 | self.app.lock.set() 160 | self.app.info = cmd 161 | 162 | if cmd["type"] == "CHANNELRESPONSE": 163 | self.new_channel() 164 | 165 | def reply(self, msg): 166 | if isinstance(self.ws_connection, type(None)): 167 | self.buf.append(msg) 168 | else: 169 | self.write_message((json.dumps(msg))) 170 | 171 | def new_channel(self): 172 | logging.info("NEWCHANNEL for app %s" % (self.app.info["name"])) 173 | ws = "ws://localhost:8008/receiver/%s" % self.app.info["name"] 174 | self.reply( 175 | { 176 | "type": "NEWCHANNEL", 177 | "senderId": self.senderid, 178 | "requestId": self.app.get_apps_count(), 179 | "URL": ws 180 | } 181 | ) 182 | 183 | def new_request(self, data=None): 184 | logging.info("CHANNELREQUEST for app %s" % (self.app.info["name"])) 185 | if data: 186 | try: 187 | data = json.loads(data) 188 | self.senderid = data["senderId"] 189 | except Exception: 190 | self.senderid = self.app.get_apps_count() 191 | else: 192 | self.senderid = self.app.get_apps_count() 193 | 194 | self.reply( 195 | { 196 | "type": "CHANNELREQUEST", 197 | "senderId": self.senderid, 198 | "requestId": self.app.get_apps_count(), 199 | } 200 | ) 201 | 202 | def on_close(self): 203 | self.app.stop() 204 | 205 | 206 | class WSC(tornado.websocket.WebSocketHandler): 207 | 208 | def open(self, app=None): 209 | self.app = App.get_instance(app) 210 | self.cname = self.__class__.__name__ 211 | 212 | logging.info("%s opened %s" % 213 | (self.cname, self.request.uri)) 214 | 215 | def on_message(self, message): 216 | if Environment.verbosity is logging.DEBUG: 217 | pretty = json.loads(message) 218 | message = json.dumps( 219 | pretty, sort_keys=True, indent=2) 220 | logging.debug("%s: %s" % (self.cname, message)) 221 | 222 | def on_close(self): 223 | if self.app.name in Environment.channels: 224 | del Environment.channels[self.app.name] 225 | logging.info("%s closed %s" % 226 | (self.cname, self.request.uri)) 227 | 228 | 229 | class ReceiverChannel(WSC): 230 | 231 | ''' 232 | ws /receiver/$app 233 | From 1st screen app 234 | ''' 235 | 236 | def open(self, app=None): 237 | super(ReceiverChannel, self).open(app) 238 | self.app.add_receiver(self) 239 | 240 | queue = self.app.get_deque(self) 241 | while len(queue) > 0: 242 | self.on_message(queue.pop()) 243 | 244 | def on_message(self, message): 245 | channel = self.app.get_app_channel(self) 246 | if channel: 247 | queue = self.app.get_deque(self) 248 | while len(queue) > 0: 249 | self.on_message(queue.pop()) 250 | 251 | super(ReceiverChannel, self).on_message(message) 252 | channel.write_message(message) 253 | else: 254 | queue = self.app.get_deque(self) 255 | queue.append(message) 256 | 257 | def on_close(self): 258 | channel = self.app.get_app_channel(self) 259 | try: 260 | self.app.receivers.remove(self) 261 | except: 262 | pass 263 | 264 | if channel: 265 | channel.on_close() 266 | 267 | 268 | class ApplicationChannel(WSC): 269 | 270 | ''' 271 | ws /session/$app 272 | From 2nd screen app 273 | ''' 274 | 275 | def ping(self): 276 | self.app.get_deque(self) 277 | 278 | channel = self.app.get_self_app_channel(self) 279 | if channel: 280 | data = json.dumps(["cm", {"type": "ping", "cmd_id": 0}]) 281 | channel.write_message(data) 282 | # TODO Magic number -- Not sure what the interval should be, the 283 | # value of `pingInterval` is 0. 284 | threading.Timer(5, self.ping).start() 285 | 286 | def open(self, app=None): 287 | super(ApplicationChannel, self).open(app) 288 | self.app.add_remote(self) 289 | self.app.get_deque(self) 290 | 291 | self.ping() 292 | 293 | def on_message(self, message): 294 | channel = self.app.get_recv_channel(self) 295 | if channel: 296 | queue = self.app.get_deque(self) 297 | while len(queue) > 0: 298 | self.on_message(queue.pop()) 299 | 300 | super(ApplicationChannel, self).on_message(message) 301 | channel.write_message(message) 302 | else: 303 | queue = self.app.get_deque(self) 304 | queue.append(message) 305 | 306 | def on_close(self): 307 | channel = self.app.get_recv_channel(self) 308 | try: 309 | self.app.remotes.remove(self) 310 | except: 311 | pass 312 | 313 | if channel: 314 | channel.on_close() 315 | 316 | 317 | class CastPlatform(tornado.websocket.WebSocketHandler): 318 | 319 | ''' 320 | Remote control over WebSocket. 321 | 322 | Commands are: 323 | {u'type': u'GET_VOLUME', u'cmd_id': 1} 324 | {u'type': u'GET_MUTED', u'cmd_id': 2} 325 | {u'type': u'VOLUME_CHANGED', u'cmd_id': 3} 326 | {u'type': u'SET_VOLUME', u'cmd_id': 4} 327 | {u'type': u'SET_MUTED', u'cmd_id': 5} 328 | 329 | Device control: 330 | 331 | ''' 332 | 333 | def on_message(self, message): 334 | pass 335 | -------------------------------------------------------------------------------- /leapcast/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | from __future__ import unicode_literals 4 | from tornado.template import Template 5 | from textwrap import dedent 6 | import threading 7 | 8 | 9 | def render(template): 10 | return Template(dedent(template)) 11 | 12 | 13 | class ControlMixin(object): 14 | 15 | def __init__(self, handler, poll_interval): 16 | self._thread = None 17 | self.poll_interval = poll_interval 18 | self._handler = handler 19 | 20 | def start(self): 21 | self._thread = t = threading.Thread(target=self.serve_forever, 22 | args=(self.poll_interval,)) 23 | t.setDaemon(True) 24 | t.start() 25 | 26 | def stop(self): 27 | self.shutdown() 28 | self._thread.join() 29 | self._thread = None 30 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools 2 | tornado >= 3.1 3 | requests 4 | --------------------------------------------------------------------------------