├── .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 |
--------------------------------------------------------------------------------