pax_global_header 0000666 0000000 0000000 00000000064 15114244474 0014520 g ustar 00root root 0000000 0000000 52 comment=0ed8dab9f2e7e120d7569fe1c4c27be97d8be0f8
emulated_roku-0.4.0/ 0000775 0000000 0000000 00000000000 15114244474 0014361 5 ustar 00root root 0000000 0000000 emulated_roku-0.4.0/.github/ 0000775 0000000 0000000 00000000000 15114244474 0015721 5 ustar 00root root 0000000 0000000 emulated_roku-0.4.0/.github/workflows/ 0000775 0000000 0000000 00000000000 15114244474 0017756 5 ustar 00root root 0000000 0000000 emulated_roku-0.4.0/.github/workflows/python-publish.yml 0000664 0000000 0000000 00000004252 15114244474 0023471 0 ustar 00root root 0000000 0000000 # This workflow will upload a Python Package to PyPI when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Build release distributions
run: |
# NOTE: put your own distribution build steps here.
python -m pip install build
python -m build
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Dedicated environments with protections for publishing are strongly recommended.
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
environment:
name: pypi
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
# url: https://pypi.org/p/YOURPROJECT
#
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
# ALTERNATIVE: exactly, uncomment the following line instead:
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
emulated_roku-0.4.0/.gitignore 0000664 0000000 0000000 00000002053 15114244474 0016351 0 ustar 00root root 0000000 0000000 # 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/ emulated_roku-0.4.0/LICENSE 0000664 0000000 0000000 00000002055 15114244474 0015370 0 ustar 00root root 0000000 0000000 MIT 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.
emulated_roku-0.4.0/README.md 0000664 0000000 0000000 00000001240 15114244474 0015635 0 ustar 00root root 0000000 0000000 # 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.
Application list can be customized with a string in this format :
`1:first-app,2:second-app,3:third-app`
Which would generate this response :
```angular2html
first-app
second-app
third-app
``` emulated_roku-0.4.0/advertise.py 0000664 0000000 0000000 00000004436 15114244474 0016730 0 ustar 00root root 0000000 0000000 """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()
emulated_roku-0.4.0/emulated_roku/ 0000775 0000000 0000000 00000000000 15114244474 0017221 5 ustar 00root root 0000000 0000000 emulated_roku-0.4.0/emulated_roku/__init__.py 0000664 0000000 0000000 00000043010 15114244474 0021330 0 ustar 00root root 0000000 0000000 """Emulated Roku library."""
import re
import socket
from aiohttp import web
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
from uuid import NAMESPACE_OID, uuid5
_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_DEFAULT = """
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
"""
APP_TEMPLATE = "{app_name}\r\n"
APPS_TEMPLATE = """
{}
"""
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" \
"SERVER: Roku/12.0.0 UPnP/1.0 Roku/12.0.0\r\n" \
"Ext: \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, custom_apps: str = 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
if custom_apps is not None:
self.custom_apps = build_custom_apps(custom_apps)
else:
self.custom_apps = None
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):
if self.custom_apps is not None:
return web.Response(body=self.custom_apps,
headers={'Content-Type': 'text/xml'})
else:
return web.Response(body=APPS_TEMPLATE_DEFAULT,
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()
# Generates the application XML based on custom apps string
def build_custom_apps(custom_apps: str) -> str:
app_append = ""
app_split = re.split(r'[\n,]', custom_apps)
for app in app_split:
if ":" in app:
app_values = app.split(":")
app_append += APP_TEMPLATE.format(app_id= app_values[0].strip(), app_name=app_values[1].strip())
else:
_LOGGER.warning("roku_api:invalid custom app value '%s'", app)
if app_append == "":
return None
else:
return APPS_TEMPLATE.format(app_append)
emulated_roku-0.4.0/example.py 0000775 0000000 0000000 00000001160 15114244474 0016367 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python3
"""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,
custom_apps = None
)
await roku_api.start()
loop = asyncio.get_event_loop()
loop.run_until_complete(start_emulated_roku(loop))
loop.run_forever()
emulated_roku-0.4.0/pyproject.toml 0000664 0000000 0000000 00000001610 15114244474 0017273 0 ustar 00root root 0000000 0000000 [build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "emulated_roku"
version = "0.4.0"
description = "Library to emulate the Roku API for Home Assistant and other automation tools."
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "Marton Perei", email = "marton@perei.me"}
]
keywords = ["roku", "emulation", "home-assistant", "automation", "aiohttp"]
classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
]
dependencies = [
"aiohttp",
]
[project.urls]
"Homepage" = "https://github.com/martonperei/emulated_roku"
"Bug Tracker" = "https://github.com/martonperei/emulated_roku/issues"
[tool.setuptools.packages.find]
# This ensures setuptools finds the 'emulated_roku' directory in the root
include = ["emulated_roku*"]
emulated_roku-0.4.0/setup.cfg 0000664 0000000 0000000 00000000047 15114244474 0016203 0 ustar 00root root 0000000 0000000 [metadata]
description-file = README.md emulated_roku-0.4.0/setup.py 0000664 0000000 0000000 00000001106 15114244474 0016071 0 ustar 00root root 0000000 0000000 """Emulated Roku library."""
from setuptools import setup
setup(name="emulated_roku",
version="0.4.0",
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.4.0",
author="mindigmarton",
license="MIT",
packages=["emulated_roku"],
install_requires=["aiohttp>3"],
zip_safe=True)