pax_global_header00006660000000000000000000000064136122741260014516gustar00rootroot0000000000000052 comment=40e1c39d4c26bcf2ebe1d3b43b2c45c030233304 martonperei-emulated_roku-40e1c39/000077500000000000000000000000001361227412600172335ustar00rootroot00000000000000martonperei-emulated_roku-40e1c39/.gitignore000066400000000000000000000020531361227412600212230ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv .venv/ venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject .idea/martonperei-emulated_roku-40e1c39/LICENSE000066400000000000000000000020551361227412600202420ustar00rootroot00000000000000MIT License Copyright (c) 2017 Marton Perei Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. martonperei-emulated_roku-40e1c39/README.md000066400000000000000000000005321361227412600205120ustar00rootroot00000000000000# emulated_roku This library is for emulating the Roku API. Discovery is tested with Logitech Harmony and Android remotes. Only key press / down / up events and app launches (10 dummy apps) are implemented in the RokuCommandHandler callback. Other functionality such as input, search will not work. See the [example](example.py) on how to use.martonperei-emulated_roku-40e1c39/advertise.py000066400000000000000000000044361361227412600216020ustar00rootroot00000000000000"""Advertise an emulated Roku API on the specified address.""" if __name__ == "__main__": import logging from asyncio import get_event_loop from argparse import ArgumentParser from os import name as osname import socket from emulated_roku import EmulatedRokuDiscoveryProtocol, \ get_local_ip, \ MULTICAST_GROUP, MULTICAST_PORT logging.basicConfig(level=logging.DEBUG) parser = ArgumentParser(description='Advertise an emulated Roku API on the specified address.') parser.add_argument('--multicast_ip', type=str, help='Multicast interface to listen on') parser.add_argument('--api_ip', type=str, required=True, help='IP address of the emulated Roku API') parser.add_argument('--api_port', type=int, required=True, help='Port of the emulated Roku API.') parser.add_argument('--name', type=str, default="Home Assistant", help='Name of the emulated Roku instance') parser.add_argument('--bind_multicast', type=bool, help='Whether to bind the multicast group or interface') args = parser.parse_args() async def start_emulated_roku(loop): multicast_ip = args.multicast_ip if args.multicast_ip else get_local_ip() bind_multicast = args.bind_multicast if args.bind_multicast else osname != "nt" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton(multicast_ip)) if bind_multicast: sock.bind(("", MULTICAST_PORT)) else: sock.bind((multicast_ip, MULTICAST_PORT)) _, discovery_proto = await loop.create_datagram_endpoint( lambda: EmulatedRokuDiscoveryProtocol(loop, multicast_ip, args.name, args.api_ip, args.api_port), sock=sock) loop = get_event_loop() loop.run_until_complete(start_emulated_roku(loop)) loop.run_forever() martonperei-emulated_roku-40e1c39/emulated_roku/000077500000000000000000000000001361227412600220735ustar00rootroot00000000000000martonperei-emulated_roku-40e1c39/emulated_roku/__init__.py000066400000000000000000000405461361227412600242150ustar00rootroot00000000000000"""Emulated Roku library.""" from asyncio import ( AbstractEventLoop, DatagramProtocol, DatagramTransport, Task, sleep) from base64 import b64decode from ipaddress import ip_address from logging import getLogger from os import name as osname from random import randrange import socket from uuid import NAMESPACE_OID, uuid5 from aiohttp import web _LOGGER = getLogger(__name__) APP_PLACEHOLDER_ICON = b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgDTD2" "qgAAAAASUVORK5CYII=") INFO_TEMPLATE = """ 1 0 urn:roku-com:device:player:1-0 {usn} Roku http://www.roku.com/ Emulated Roku Roku 4 4400x http://www.roku.com/ {usn} uuid:{uuid} """ DEVICE_INFO_TEMPLATE = """ {uuid} {usn} {usn} Roku 4400X Roku 4 US true 00:00:00:00:00:00 00:00:00:00:00:00 ethernet {usn} 7.5.0 09021 true en US en_US US/Pacific -480 PowerOn false false false false 0000000000000000000000000000000000000000 false false false false false false """ APPS_TEMPLATE = """ Emulated App 1 Emulated App 2 Emulated App 3 Emulated App 4 Emulated App 5 Emulated App 6 Emulated App 7 Emulated App 8 Emulated App 9 Emulated App 10 """ ACTIVE_APP_TEMPLATE = """ Roku """ MUTLICAST_TTL = 300 MULTICAST_MAX_DELAY = 5 MULTICAST_GROUP = "239.255.255.250" MULTICAST_PORT = 1900 MULTICAST_RESPONSE = "HTTP/1.1 200 OK\r\n" \ "Cache-Control: max-age = {ttl}\r\n" \ "ST: roku:ecp\r\n" \ "Location: http://{advertise_ip}:{advertise_port}/\r\n" \ "USN: uuid:roku:ecp:{usn}\r\n" \ "\r\n" MULTICAST_NOTIFY = "NOTIFY * HTTP/1.1\r\n" \ "HOST: {multicast_ip}:{multicast_port}\r\n" \ "Cache-Control: max-age = {ttl}\r\n" \ "NT: upnp:rootdevice\r\n" \ "NTS: ssdp:alive\r\n" \ "Location: http://{advertise_ip}:{advertise_port}/\r\n" \ "USN: uuid:roku:ecp:{usn}\r\n" \ "\r\n" class EmulatedRokuDiscoveryProtocol(DatagramProtocol): """Roku SSDP Discovery protocol.""" def __init__(self, loop: AbstractEventLoop, host_ip: str, roku_usn: str, advertise_ip: str, advertise_port: int): """Initialize the protocol.""" self.loop = loop self.host_ip = host_ip self.roku_usn = roku_usn self.advertise_ip = advertise_ip self.advertise_port = advertise_port self.ssdp_response = MULTICAST_RESPONSE.format( advertise_ip=advertise_ip, advertise_port=advertise_port, usn=roku_usn, ttl=MUTLICAST_TTL) self.notify_broadcast = MULTICAST_NOTIFY.format( advertise_ip=advertise_ip, advertise_port=advertise_port, multicast_ip=MULTICAST_GROUP, multicast_port=MULTICAST_PORT, usn=roku_usn, ttl=MUTLICAST_TTL) self.notify_task = None # type: Task self.transport = None # type: DatagramTransport def connection_made(self, transport): """Set up the multicast socket and schedule the NOTIFY message.""" self.transport = transport _LOGGER.debug("multicast:started for %s/%s:%s/usn:%s", MULTICAST_GROUP, self.advertise_ip, self.advertise_port, self.roku_usn) self.notify_task = self.loop.create_task(self._multicast_notify()) def connection_lost(self, exc): """Clean up the protocol.""" _LOGGER.debug("multicast:connection_lost for %s/%s:%s/usn:%s", MULTICAST_GROUP, self.advertise_ip, self.advertise_port, self.roku_usn) self.close() async def _multicast_notify(self) -> None: """Broadcast a NOTIFY multicast message.""" while self.transport and not self.transport.is_closing(): _LOGGER.debug("multicast:broadcast\n%s", self.notify_broadcast) self.transport.sendto(self.notify_broadcast.encode(), (MULTICAST_GROUP, MULTICAST_PORT)) await sleep(MUTLICAST_TTL) def _multicast_reply(self, data: str, addr: tuple) -> None: """Reply to a discovery message.""" if self.transport is None or self.transport.is_closing(): return _LOGGER.debug("multicast:reply %s\n%s", addr, self.ssdp_response) self.transport.sendto(self.ssdp_response.encode('utf-8'), addr) def datagram_received(self, data, addr): """Parse the received datagram and send a reply if needed.""" data = data.decode('utf-8', errors='ignore') if data.startswith("M-SEARCH * HTTP/1.1") and \ ("ST: ssdp:all" in data or "ST: roku:ecp" in data): _LOGGER.debug("multicast:request %s\n%s", addr, data) mx_value = data.find("MX:") if mx_value != -1: mx_delay = int(data[mx_value + 4]) % (MULTICAST_MAX_DELAY + 1) delay = randrange(0, mx_delay + 1, 1) else: delay = randrange(0, MULTICAST_MAX_DELAY + 1, 1) self.loop.call_later(delay, self._multicast_reply, data, addr) def close(self) -> None: """Close the discovery transport.""" if self.notify_task: self.notify_task.cancel() self.notify_task = None if self.transport: self.transport.close() self.transport = None class EmulatedRokuCommandHandler: """Base handler class for Roku commands.""" KEY_HOME = 'Home' KEY_REV = 'Rev' KEY_FWD = 'Fwd' KEY_PLAY = 'Play' KEY_SELECT = 'Select' KEY_LEFT = 'Left' KEY_RIGHT = 'Right' KEY_DOWN = 'Down' KEY_UP = 'Up' KEY_BACK = 'Back' KEY_INSTANTREPLAY = 'InstantReplay' KEY_INFO = 'Info' KEY_BACKSPACE = 'Backspace' KEY_SEARCH = 'Search' KEY_ENTER = 'Enter' KEY_FINDREMOTE = 'FindRemote' KEY_VOLUMEDOWN = 'VolumeDown' KEY_VOLUMEMUTE = 'VolumeMute' KEY_VOLUMEUP = 'VolumeUp' KEY_POWEROFF = 'PowerOff' KEY_CHANNELUP = 'ChannelUp' KEY_CHANNELDOWN = 'ChannelDown' KEY_INPUTTUNER = 'InputTuner' KEY_INPUTHDMI1 = 'InputHDMI1' KEY_INPUTHDMI2 = 'InputHDMI2' KEY_INPUTHDMI3 = 'InputHDMI3' KEY_INPUTHDMI4 = 'InputHDMI4' KEY_INPUTAV1 = 'InputAV1' def on_keydown(self, roku_usn: str, key: str) -> None: """Handle key down command.""" pass def on_keyup(self, roku_usn: str, key: str) -> None: """Handle key up command.""" pass def on_keypress(self, roku_usn: str, key: str) -> None: """Handle key press command.""" pass def launch(self, roku_usn: str, app_id: str) -> None: """Handle launch command.""" pass class EmulatedRokuServer: """Emulated Roku server. Handles the API HTTP server and UPNP discovery. """ def __init__(self, loop: AbstractEventLoop, handler: EmulatedRokuCommandHandler, roku_usn: str, host_ip: str, listen_port: int, advertise_ip: str = None, advertise_port: int = None, bind_multicast: bool = None): """Initialize the Roku API server.""" self.loop = loop self.handler = handler self.roku_usn = roku_usn self.host_ip = host_ip self.listen_port = listen_port self.advertise_ip = advertise_ip or host_ip self.advertise_port = advertise_port or listen_port self.allowed_hosts = ( self.host_ip, "{}:{}".format(self.host_ip, self.listen_port), self.advertise_ip, "{}:{}".format(self.advertise_ip, self.advertise_port)) if bind_multicast is None: # do not bind multicast group on windows by default self.bind_multicast = osname != "nt" else: self.bind_multicast = bind_multicast self.roku_uuid = str(uuid5(NAMESPACE_OID, roku_usn)) self.roku_info = INFO_TEMPLATE.format(uuid=self.roku_uuid, usn=roku_usn) self.device_info = DEVICE_INFO_TEMPLATE.format(uuid=self.roku_uuid, usn=self.roku_usn) self.discovery_proto = None # type: EmulatedRokuDiscoveryProtocol self.api_runner = None # type: web.AppRunner async def _roku_root_handler(self, request): return web.Response(body=self.roku_info, headers={'Content-Type': 'text/xml'}) async def _roku_input_handler(self, request): return web.Response() async def _roku_keydown_handler(self, request): key = request.match_info['key'] self.handler.on_keydown(self.roku_usn, key) return web.Response() async def _roku_keyup_handler(self, request): key = request.match_info['key'] self.handler.on_keyup(self.roku_usn, key) return web.Response() async def _roku_keypress_handler(self, request): key = request.match_info['key'] self.handler.on_keypress(self.roku_usn, key) return web.Response() async def _roku_launch_handler(self, request): app_id = request.match_info['id'] self.handler.launch(self.roku_usn, app_id) return web.Response() async def _roku_apps_handler(self, request): return web.Response(body=APPS_TEMPLATE, headers={'Content-Type': 'text/xml'}) async def _roku_active_app_handler(self, request): return web.Response(body=ACTIVE_APP_TEMPLATE, headers={'Content-Type': 'text/xml'}) async def _roku_app_icon_handler(self, request): return web.Response(body=APP_PLACEHOLDER_ICON, headers={'Content-Type': 'image/png'}) async def _roku_search_handler(self, request): return web.Response() async def _roku_info_handler(self, request): return web.Response(body=self.device_info, headers={'Content-Type': 'text/xml'}) @web.middleware async def _check_remote_and_host_ip(self, request, handler): # only allow access by advertised address or bound ip:[port] # (prevents dns rebinding) if request.host not in self.allowed_hosts: _LOGGER.warning("Rejected non-advertised access by host %s", request.host) raise web.HTTPForbidden # only allow local network access if not ip_address(request.remote).is_private: _LOGGER.warning("Rejected non-local access from remote %s", request.remote) raise web.HTTPForbidden return await handler(request) async def _setup_app(self) -> web.AppRunner: app = web.Application(loop=self.loop, middlewares=[self._check_remote_and_host_ip]) app.router.add_route('GET', "/", self._roku_root_handler) app.router.add_route('POST', "/keydown/{key}", self._roku_keydown_handler) app.router.add_route('POST', "/keyup/{key}", self._roku_keyup_handler) app.router.add_route('POST', "/keypress/{key}", self._roku_keypress_handler) app.router.add_route('POST', "/launch/{id}", self._roku_launch_handler) app.router.add_route('POST', "/input", self._roku_input_handler) app.router.add_route('POST', "/search", self._roku_search_handler) app.router.add_route('GET', "/query/apps", self._roku_apps_handler) app.router.add_route('GET', "/query/icon/{id}", self._roku_app_icon_handler) app.router.add_route('GET', "/query/active-app", self._roku_active_app_handler) app.router.add_route('GET', "/query/device-info", self._roku_info_handler) api_runner = web.AppRunner(app) await api_runner.setup() return api_runner async def start(self) -> None: """Start the Roku API server and discovery endpoint.""" _LOGGER.debug("roku_api:starting server %s:%s", self.host_ip, self.listen_port) # set up the HTTP server self.api_runner = await self._setup_app() api_endpoint = web.TCPSite(self.api_runner, self.host_ip, self.listen_port) await api_endpoint.start() self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton(self.host_ip)) if self.bind_multicast: self.sock.bind(("", MULTICAST_PORT)) else: self.sock.bind((self.host_ip, MULTICAST_PORT)) # set up the SSDP discovery server _, self.discovery_proto = await self.loop.create_datagram_endpoint( lambda: EmulatedRokuDiscoveryProtocol(self.loop, self.host_ip, self.roku_usn, self.advertise_ip, self.advertise_port), sock=self.sock) async def close(self) -> None: """Close the Roku API server and discovery endpoint.""" _LOGGER.debug("roku_api:closing server %s:%s", self.host_ip, self.listen_port) if self.discovery_proto: self.discovery_proto.close() self.discovery_proto = None if self.api_runner: await self.api_runner.cleanup() self.api_runner = None # Taken from: http://stackoverflow.com/a/11735897 def get_local_ip() -> str: """Try to determine the local IP address of the machine.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Use Google Public DNS server to determine own IP sock.connect(('8.8.8.8', 80)) return sock.getsockname()[0] # type: ignore except socket.error: try: return socket.gethostbyname(socket.gethostname()) except socket.gaierror: return '127.0.0.1' finally: sock.close() martonperei-emulated_roku-40e1c39/example.py000066400000000000000000000010701361227412600212360ustar00rootroot00000000000000"""Example script for using the Emulated Roku api.""" if __name__ == "__main__": import asyncio import logging import emulated_roku logging.basicConfig(level=logging.DEBUG) async def start_emulated_roku(loop): roku_api = emulated_roku.EmulatedRokuServer( loop, emulated_roku.EmulatedRokuCommandHandler(), "test_roku", emulated_roku.get_local_ip(), 8060 ) await roku_api.start() loop = asyncio.get_event_loop() loop.run_until_complete(start_emulated_roku(loop)) loop.run_forever() martonperei-emulated_roku-40e1c39/setup.cfg000066400000000000000000000000471361227412600210550ustar00rootroot00000000000000[metadata] description-file = README.mdmartonperei-emulated_roku-40e1c39/setup.py000066400000000000000000000011061361227412600207430ustar00rootroot00000000000000"""Emulated Roku library.""" from setuptools import setup setup(name="emulated_roku", version="0.2.1", description="Library to emulate a roku server to serve as a proxy" "for remotes such as Harmony", url="https://gitlab.com/mindig.marton/emulated_roku", download_url="https://gitlab.com" "/mindig.marton/emulated_roku" "/repository/archive.zip?ref=0.2.1", author="mindigmarton", license="MIT", packages=["emulated_roku"], install_requires=["aiohttp>2"], zip_safe=True)