pax_global_header 0000666 0000000 0000000 00000000064 15145157253 0014522 g ustar 00root root 0000000 0000000 52 comment=8bd6a550315fe45b6f825a75ab8ba54e6f157b9b
martonperei-emulated_roku-8bd6a55/ 0000775 0000000 0000000 00000000000 15145157253 0017325 5 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/.github/ 0000775 0000000 0000000 00000000000 15145157253 0020665 5 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/.github/workflows/ 0000775 0000000 0000000 00000000000 15145157253 0022722 5 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/.github/workflows/python-publish.yml 0000664 0000000 0000000 00000004055 15145157253 0026436 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
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install dependencies
run: pip install .[test]
- name: Run linter
run: ruff check .
- name: Run type check
run: python -m mypy emulated_roku
- name: Run tests
run: pytest tests/ -v
release-build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Build release distributions
run: |
python -m pip install .[release]
python -m build
- name: Validate distributions
run: twine check dist/*
- name: Smoke test wheel install
run: |
python -m venv /tmp/emulated-roku-smoke
/tmp/emulated-roku-smoke/bin/pip install dist/*.whl
/tmp/emulated-roku-smoke/bin/python -c "import emulated_roku; print(emulated_roku.__name__)"
- 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
environment:
name: pypi
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/
martonperei-emulated_roku-8bd6a55/.github/workflows/test.yml 0000664 0000000 0000000 00000001140 15145157253 0024420 0 ustar 00root root 0000000 0000000 name: Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install .[test]
- name: Run linter
run: ruff check .
- name: Run type check
run: python -m mypy emulated_roku
- name: Run tests
run: pytest tests/ -v
martonperei-emulated_roku-8bd6a55/.gitignore 0000664 0000000 0000000 00000002136 15145157253 0021317 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
.pytest_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/
.DS_Store
.ruff_cache/
.mypy_cache/ martonperei-emulated_roku-8bd6a55/CHANGELOG.md 0000664 0000000 0000000 00000002572 15145157253 0021144 0 ustar 00root root 0000000 0000000 # Changelog
## 0.5.1
### Fixed
- Lowered Python version floor from 3.14 to 3.12 (no 3.14-specific features were used).
- Added Python 3.12 and 3.13 to CI test matrix.
## 0.5.0
### Breaking changes
- Removed deprecated `loop` parameter from `EmulatedRokuServer` and `EmulatedRokuDiscoveryProtocol`. Uses `asyncio.get_running_loop()` / `asyncio.create_task()` directly.
- Requires Python 3.12+.
### Fixed
- Fixed `MULTICAST_TTL` constant typo (was `MUTLICAST_TTL`).
- Fixed MX header parsing in SSDP discovery — now handles multi-digit values, missing MX header, and malformed values without crashing.
- Fixed `build_custom_apps` dropping text after a second colon in app names (e.g. `1:My App: Extended`).
- Fixed `build_custom_apps` return type annotation (`str | None`).
- Fixed `get_local_ip` socket leak if `socket.socket()` constructor fails.
- Fixed `advertise_port=0` being treated as `None` due to falsy check.
### Added
- Test suite with pytest + pytest-aiohttp (47 tests).
- GitHub Actions CI workflow for tests and linting.
- Ruff linter integration.
- Publish workflow: `twine check` validation and smoke test for wheel install.
- `py.typed` marker for PEP 561.
- `[test]` optional dependency group.
### Removed
- Removed legacy `setup.py` and `setup.cfg` (replaced by `pyproject.toml`).
## 0.4.0
- Added support for custom applications list.
## 0.3.0
- Initial public release.
martonperei-emulated_roku-8bd6a55/LICENSE 0000664 0000000 0000000 00000002055 15145157253 0020334 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.
martonperei-emulated_roku-8bd6a55/README.md 0000664 0000000 0000000 00000002720 15145157253 0020605 0 ustar 00root root 0000000 0000000 # emulated_roku
Library to emulate the Roku API for Home Assistant and other automation tools. Discovery is tested with Logitech Harmony and Android remotes.
## Installation
```bash
pip install emulated_roku
```
Requires Python 3.12+.
## Usage
Subclass `EmulatedRokuCommandHandler` to handle key press / down / up events and app launches, then start the server:
```python
import asyncio
import emulated_roku
class MyHandler(emulated_roku.EmulatedRokuCommandHandler):
def on_keypress(self, roku_usn, key):
print(f"Key pressed: {key}")
def launch(self, roku_usn, app_id):
print(f"Launch app: {app_id}")
async def main():
server = emulated_roku.EmulatedRokuServer(
MyHandler(),
"my-roku",
emulated_roku.get_local_ip(),
8060,
)
await server.start()
await asyncio.Event().wait()
asyncio.run(main())
```
See [example.py](example.py) for a minimal runnable example.
## Custom apps
The application list can be customized with a comma- or newline-separated string:
```python
server = emulated_roku.EmulatedRokuServer(
handler, "my-roku", "192.168.1.10", 8060,
custom_apps="1:Netflix,2:YouTube,3:Plex",
)
```
This produces:
```xml
Netflix
YouTube
Plex
```
## Development
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .[test]
pytest tests/ -v
```
martonperei-emulated_roku-8bd6a55/advertise.py 0000664 0000000 0000000 00000004320 15145157253 0021664 0 ustar 00root root 0000000 0000000 """Advertise an emulated Roku API on the specified address."""
if __name__ == "__main__":
import logging
import asyncio
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():
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))
loop = asyncio.get_running_loop()
_, discovery_proto = await loop.create_datagram_endpoint(
lambda: EmulatedRokuDiscoveryProtocol(multicast_ip, args.name,
args.api_ip,
args.api_port),
sock=sock)
await asyncio.Event().wait()
asyncio.run(start_emulated_roku())
martonperei-emulated_roku-8bd6a55/emulated_roku/ 0000775 0000000 0000000 00000000000 15145157253 0022165 5 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/emulated_roku/__init__.py 0000664 0000000 0000000 00000043574 15145157253 0024313 0 ustar 00root root 0000000 0000000 """Emulated Roku library."""
from importlib.metadata import version
__version__ = version(__name__)
__all__ = [
"EmulatedRokuCommandHandler",
"EmulatedRokuDiscoveryProtocol",
"EmulatedRokuServer",
"build_custom_apps",
"get_local_ip",
]
import socket
from aiohttp import web
import asyncio
from asyncio import DatagramProtocol, 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
"""
MULTICAST_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, host_ip: str, roku_usn: str,
advertise_ip: str, advertise_port: int):
"""Initialize the protocol."""
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=MULTICAST_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=MULTICAST_TTL)
self.notify_task: asyncio.Task | None = None
self.transport: asyncio.DatagramTransport | None = None
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 = asyncio.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(MULTICAST_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_delay = MULTICAST_MAX_DELAY
for line in data.splitlines():
if line.upper().startswith("MX:"):
mx_raw = line.split(":", 1)[1].strip()
try:
mx_delay = max(0, min(int(mx_raw), MULTICAST_MAX_DELAY))
except ValueError:
mx_delay = MULTICAST_MAX_DELAY
break
delay = randrange(0, mx_delay + 1, 1)
asyncio.get_running_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, handler: EmulatedRokuCommandHandler,
roku_usn: str, host_ip: str, listen_port: int,
advertise_ip: str | None = None,
advertise_port: int | None = None,
bind_multicast: bool | None = None,
custom_apps: str | None = None):
"""Initialize the Roku API server."""
self.handler = handler
self.roku_usn = roku_usn
self.host_ip = host_ip
self.listen_port = listen_port
self.advertise_ip = host_ip if advertise_ip is None else advertise_ip
self.advertise_port = listen_port if advertise_port is None else advertise_port
self.allowed_hosts = (
self.host_ip,
"{}:{}".format(self.host_ip, self.listen_port),
self.advertise_ip,
"{}:{}".format(self.advertise_ip, self.advertise_port))
self.bind_multicast: bool
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: EmulatedRokuDiscoveryProtocol | None = None
self.api_runner: web.AppRunner | None = None
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 request.remote is None or 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(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
loop = asyncio.get_running_loop()
_, self.discovery_proto = await loop.create_datagram_endpoint(
lambda: EmulatedRokuDiscoveryProtocol(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:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
# Use Google Public DNS server to determine own IP
sock.connect(('8.8.8.8', 80))
return str(sock.getsockname()[0])
except socket.error:
try:
return socket.gethostbyname(socket.gethostname())
except socket.gaierror:
return '127.0.0.1'
# Generates the application XML based on custom apps string
def build_custom_apps(custom_apps: str) -> str | None:
app_append = ""
app_split = custom_apps.replace("\r", "\n").replace(",", "\n").split("\n")
for app in app_split:
if ":" in app:
app_id, app_name = app.split(":", 1)
app_append += APP_TEMPLATE.format(app_id=app_id.strip(), app_name=app_name.strip())
else:
_LOGGER.warning("roku_api:invalid custom app value '%s'", app)
if app_append == "":
return None
else:
return APPS_TEMPLATE.format(app_append)
martonperei-emulated_roku-8bd6a55/emulated_roku/py.typed 0000664 0000000 0000000 00000000000 15145157253 0023652 0 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/example.py 0000775 0000000 0000000 00000001075 15145157253 0021340 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():
roku_api = emulated_roku.EmulatedRokuServer(
emulated_roku.EmulatedRokuCommandHandler(),
"test_roku", emulated_roku.get_local_ip(), 8060,
custom_apps = None
)
await roku_api.start()
await asyncio.Event().wait()
asyncio.run(start_emulated_roku())
martonperei-emulated_roku-8bd6a55/pyproject.toml 0000664 0000000 0000000 00000002526 15145157253 0022246 0 ustar 00root root 0000000 0000000 [build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "emulated_roku"
version = "0.5.1"
description = "Library to emulate the Roku API for Home Assistant and other automation tools."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
{name = "Marton Perei", email = "marton@perei.me"}
]
keywords = ["roku", "emulation", "home-assistant", "automation", "aiohttp"]
classifiers = [
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"aiohttp",
]
[project.optional-dependencies]
test = [
"pytest",
"pytest-aiohttp",
"pytest-cov",
"ruff",
"mypy",
]
release = [
"build==1.4.0",
"twine==6.2.0",
]
[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*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py312"
line-length = 88
exclude = [".venv", "dist"]
[tool.mypy]
python_version = "3.12"
warn_unused_ignores = true
show_error_codes = true
martonperei-emulated_roku-8bd6a55/tests/ 0000775 0000000 0000000 00000000000 15145157253 0020467 5 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/tests/__init__.py 0000664 0000000 0000000 00000000000 15145157253 0022566 0 ustar 00root root 0000000 0000000 martonperei-emulated_roku-8bd6a55/tests/test_emulated_roku.py 0000664 0000000 0000000 00000051323 15145157253 0024744 0 ustar 00root root 0000000 0000000 """Tests for emulated_roku."""
import asyncio
import socket
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from emulated_roku import (
APP_PLACEHOLDER_ICON,
APPS_TEMPLATE_DEFAULT,
EmulatedRokuCommandHandler,
EmulatedRokuDiscoveryProtocol,
EmulatedRokuServer,
build_custom_apps,
get_local_ip,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def handler():
"""Create a mock command handler that tracks calls."""
h = MagicMock(spec=EmulatedRokuCommandHandler)
return h
@pytest.fixture
def server(handler):
"""Create an EmulatedRokuServer instance (not started)."""
return EmulatedRokuServer(
handler=handler,
roku_usn="test-usn",
host_ip="127.0.0.1",
listen_port=8060,
)
@pytest.fixture
async def client(server):
"""Create an aiohttp TestClient wired to the server's app."""
runner = await server._setup_app()
app = runner.app
# Shut down the runner we just created — TestServer manages its own.
await runner.cleanup()
async with TestClient(TestServer(app)) as tc:
yield tc
def _host_header(server):
"""Return a Host header value the middleware will accept."""
return {"Host": f"{server.host_ip}:{server.listen_port}"}
# ---------------------------------------------------------------------------
# 1. Server initialisation
# ---------------------------------------------------------------------------
class TestServerInit:
def test_defaults(self, handler):
s = EmulatedRokuServer(
handler=handler,
roku_usn="my-usn",
host_ip="192.168.1.10",
listen_port=8060,
)
assert s.advertise_ip == "192.168.1.10"
assert s.advertise_port == 8060
assert s.roku_usn == "my-usn"
def test_custom_advertise(self, handler):
s = EmulatedRokuServer(
handler=handler,
roku_usn="my-usn",
host_ip="192.168.1.10",
listen_port=8060,
advertise_ip="10.0.0.1",
advertise_port=9090,
)
assert s.advertise_ip == "10.0.0.1"
assert s.advertise_port == 9090
def test_bind_multicast_default_non_windows(self, handler):
with patch("emulated_roku.osname", "posix"):
s = EmulatedRokuServer(
handler=handler, roku_usn="u", host_ip="127.0.0.1",
listen_port=8060,
)
assert s.bind_multicast is True
def test_bind_multicast_default_windows(self, handler):
with patch("emulated_roku.osname", "nt"):
s = EmulatedRokuServer(
handler=handler, roku_usn="u", host_ip="127.0.0.1",
listen_port=8060,
)
assert s.bind_multicast is False
def test_bind_multicast_explicit(self, handler):
s = EmulatedRokuServer(
handler=handler, roku_usn="u", host_ip="127.0.0.1",
listen_port=8060, bind_multicast=True,
)
assert s.bind_multicast is True
def test_custom_apps_stored(self, handler):
s = EmulatedRokuServer(
handler=handler, roku_usn="u", host_ip="127.0.0.1",
listen_port=8060, custom_apps="1:App One,2:App Two",
)
assert s.custom_apps is not None
assert "App One" in s.custom_apps
def test_no_loop_parameter(self, handler):
"""EmulatedRokuServer does not accept a loop parameter."""
import inspect
sig = inspect.signature(EmulatedRokuServer.__init__)
assert "loop" not in sig.parameters
# ---------------------------------------------------------------------------
# 2. Server lifecycle
# ---------------------------------------------------------------------------
class TestServerLifecycle:
@pytest.mark.parametrize(
"bind_multicast, expected_bind",
[
(True, ("", 1900)),
(False, ("127.0.0.1", 1900)),
],
)
async def test_start_binds_expected_address(
self, handler, bind_multicast, expected_bind
):
s = EmulatedRokuServer(
handler=handler,
roku_usn="u",
host_ip="127.0.0.1",
listen_port=8060,
bind_multicast=bind_multicast,
)
mock_runner = MagicMock()
mock_site = MagicMock()
mock_site.start = AsyncMock()
mock_socket = MagicMock()
mock_loop = MagicMock()
mock_transport = MagicMock()
mock_proto = MagicMock()
mock_loop.create_datagram_endpoint = AsyncMock(
return_value=(mock_transport, mock_proto)
)
with (
patch.object(s, "_setup_app", AsyncMock(return_value=mock_runner)),
patch("emulated_roku.web.TCPSite", return_value=mock_site),
patch("emulated_roku.socket.socket", return_value=mock_socket),
patch("emulated_roku.asyncio.get_running_loop", return_value=mock_loop),
):
await s.start()
mock_site.start.assert_awaited_once()
mock_socket.bind.assert_called_once_with(expected_bind)
mock_loop.create_datagram_endpoint.assert_awaited_once()
assert s.api_runner is mock_runner
assert s.discovery_proto is mock_proto
async def test_close_cleans_up_runner_and_discovery(self, handler):
s = EmulatedRokuServer(
handler=handler,
roku_usn="u",
host_ip="127.0.0.1",
listen_port=8060,
)
discovery = MagicMock()
runner = MagicMock()
runner.cleanup = AsyncMock()
s.discovery_proto = discovery
s.api_runner = runner
await s.close()
discovery.close.assert_called_once()
runner.cleanup.assert_awaited_once()
assert s.discovery_proto is None
assert s.api_runner is None
# ---------------------------------------------------------------------------
# 3. HTTP API handlers
# ---------------------------------------------------------------------------
class TestHTTPHandlers:
@pytest.fixture(autouse=True)
async def _setup(self, client, server):
self.client = client
self.server = server
self.headers = _host_header(server)
async def test_root_returns_xml(self):
resp = await self.client.get("/", headers=self.headers)
assert resp.status == 200
assert resp.headers["Content-Type"] == "text/xml"
body = await resp.text()
assert "test-usn" in body
assert "urn:roku-com:device:player:1-0" in body
async def test_query_apps_default(self):
resp = await self.client.get("/query/apps", headers=self.headers)
assert resp.status == 200
assert resp.headers["Content-Type"] == "text/xml"
body = await resp.text()
assert body == APPS_TEMPLATE_DEFAULT
async def test_query_apps_custom(self, handler):
custom_server = EmulatedRokuServer(
handler=handler, roku_usn="test-usn",
host_ip="127.0.0.1", listen_port=8060,
custom_apps="100:Netflix,200:YouTube",
)
runner = await custom_server._setup_app()
await runner.cleanup()
async with TestClient(TestServer(runner.app)) as tc:
headers = _host_header(custom_server)
resp = await tc.get("/query/apps", headers=headers)
assert resp.status == 200
body = await resp.text()
assert "Netflix" in body
assert "YouTube" in body
async def test_query_active_app(self):
resp = await self.client.get("/query/active-app", headers=self.headers)
assert resp.status == 200
body = await resp.text()
assert "Roku" in body
async def test_query_icon(self):
resp = await self.client.get("/query/icon/1", headers=self.headers)
assert resp.status == 200
assert resp.headers["Content-Type"] == "image/png"
body = await resp.read()
assert body == APP_PLACEHOLDER_ICON
async def test_query_device_info(self):
resp = await self.client.get("/query/device-info", headers=self.headers)
assert resp.status == 200
assert resp.headers["Content-Type"] == "text/xml"
body = await resp.text()
assert "test-usn" in body
assert "Roku 4" in body
async def test_keypress(self):
resp = await self.client.post("/keypress/Home", headers=self.headers)
assert resp.status == 200
self.server.handler.on_keypress.assert_called_once_with("test-usn", "Home")
async def test_keydown(self):
resp = await self.client.post("/keydown/Left", headers=self.headers)
assert resp.status == 200
self.server.handler.on_keydown.assert_called_once_with("test-usn", "Left")
async def test_keyup(self):
resp = await self.client.post("/keyup/Right", headers=self.headers)
assert resp.status == 200
self.server.handler.on_keyup.assert_called_once_with("test-usn", "Right")
async def test_launch(self):
resp = await self.client.post("/launch/12345", headers=self.headers)
assert resp.status == 200
self.server.handler.launch.assert_called_once_with("test-usn", "12345")
async def test_input(self):
resp = await self.client.post("/input", headers=self.headers)
assert resp.status == 200
async def test_search(self):
resp = await self.client.post("/search", headers=self.headers)
assert resp.status == 200
# ---------------------------------------------------------------------------
# 3. Command handler callbacks
# ---------------------------------------------------------------------------
class TestCommandHandler:
def test_subclass_callbacks(self):
calls = []
class MyHandler(EmulatedRokuCommandHandler):
def on_keypress(self, roku_usn, key):
calls.append(("keypress", roku_usn, key))
def on_keydown(self, roku_usn, key):
calls.append(("keydown", roku_usn, key))
def on_keyup(self, roku_usn, key):
calls.append(("keyup", roku_usn, key))
def launch(self, roku_usn, app_id):
calls.append(("launch", roku_usn, app_id))
h = MyHandler()
h.on_keypress("usn1", "Home")
h.on_keydown("usn1", "Left")
h.on_keyup("usn1", "Right")
h.launch("usn1", "99")
assert calls == [
("keypress", "usn1", "Home"),
("keydown", "usn1", "Left"),
("keyup", "usn1", "Right"),
("launch", "usn1", "99"),
]
def test_base_handler_noop(self):
"""Base handler methods don't raise."""
h = EmulatedRokuCommandHandler()
h.on_keypress("u", "k")
h.on_keydown("u", "k")
h.on_keyup("u", "k")
h.launch("u", "a")
# ---------------------------------------------------------------------------
# 5. Host/IP middleware
# ---------------------------------------------------------------------------
class TestMiddleware:
@pytest.fixture(autouse=True)
async def _setup(self, client, server):
self.client = client
self.server = server
async def test_allowed_host(self):
resp = await self.client.get(
"/", headers={"Host": self.server.host_ip}
)
assert resp.status == 200
async def test_allowed_host_with_port(self):
resp = await self.client.get(
"/", headers={"Host": f"{self.server.host_ip}:{self.server.listen_port}"}
)
assert resp.status == 200
async def test_rejected_host(self):
resp = await self.client.get(
"/", headers={"Host": "evil.example.com"}
)
assert resp.status == 403
async def test_rejected_non_local_remote(self, server):
request = MagicMock()
request.host = f"{server.host_ip}:{server.listen_port}"
request.remote = "8.8.8.8"
handler = AsyncMock()
with pytest.raises(web.HTTPForbidden):
await server._check_remote_and_host_ip(request, handler)
handler.assert_not_awaited()
# ---------------------------------------------------------------------------
# 6. Discovery protocol
# ---------------------------------------------------------------------------
class TestDiscoveryProtocol:
def test_no_loop_parameter(self):
import inspect
sig = inspect.signature(EmulatedRokuDiscoveryProtocol.__init__)
assert "loop" not in sig.parameters
def test_construction(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="192.168.1.1", roku_usn="my-usn",
advertise_ip="192.168.1.1", advertise_port=8060,
)
assert proto.host_ip == "192.168.1.1"
assert proto.roku_usn == "my-usn"
assert proto.notify_task is None
assert proto.transport is None
async def test_connection_made_schedules_notify(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.connection_made(transport)
assert proto.transport is transport
assert proto.notify_task is not None
assert isinstance(proto.notify_task, asyncio.Task)
# Clean up the task
proto.close()
async def test_datagram_received_msearch(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.transport = transport
msearch = (
"M-SEARCH * HTTP/1.1\r\n"
"HOST: 239.255.255.250:1900\r\n"
"MAN: \"ssdp:discover\"\r\n"
"MX: 3\r\n"
"ST: roku:ecp\r\n"
"\r\n"
).encode()
with patch("emulated_roku.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value = MagicMock()
proto.datagram_received(msearch, ("192.168.1.50", 12345))
mock_loop.return_value.call_later.assert_called_once()
async def test_datagram_received_non_msearch_ignored(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.transport = transport
data = b"HTTP/1.1 200 OK\r\nSomething\r\n\r\n"
with patch("emulated_roku.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value = MagicMock()
proto.datagram_received(data, ("192.168.1.50", 12345))
mock_loop.return_value.call_later.assert_not_called()
async def test_datagram_received_msearch_without_mx(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.transport = transport
msearch = (
"M-SEARCH * HTTP/1.1\r\n"
"HOST: 239.255.255.250:1900\r\n"
"MAN: \"ssdp:discover\"\r\n"
"ST: roku:ecp\r\n"
"\r\n"
).encode()
with patch("emulated_roku.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value = MagicMock()
proto.datagram_received(msearch, ("192.168.1.50", 12345))
mock_loop.return_value.call_later.assert_called_once()
async def test_datagram_received_msearch_with_malformed_mx(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.transport = transport
msearch = (
"M-SEARCH * HTTP/1.1\r\n"
"HOST: 239.255.255.250:1900\r\n"
"MAN: \"ssdp:discover\"\r\n"
"MX: x\r\n"
"ST: roku:ecp\r\n"
"\r\n"
).encode()
with patch("emulated_roku.asyncio.get_running_loop") as mock_loop:
mock_loop.return_value = MagicMock()
proto.datagram_received(msearch, ("192.168.1.50", 12345))
mock_loop.return_value.call_later.assert_called_once()
async def test_connection_lost_closes_protocol(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
proto.close = MagicMock()
proto.connection_lost(None)
proto.close.assert_called_once()
async def test_multicast_notify_sends_broadcast(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.side_effect = [False, True]
proto.transport = transport
with patch("emulated_roku.sleep", AsyncMock()):
await proto._multicast_notify()
transport.sendto.assert_called_once()
def test_multicast_reply_sends_to_requester(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.transport = transport
proto._multicast_reply("M-SEARCH * HTTP/1.1", ("192.168.1.50", 12345))
transport.sendto.assert_called_once()
def test_multicast_reply_ignored_when_transport_closing(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = True
proto.transport = transport
proto._multicast_reply("M-SEARCH * HTTP/1.1", ("192.168.1.50", 12345))
transport.sendto.assert_not_called()
async def test_close_cancels_notify(self):
proto = EmulatedRokuDiscoveryProtocol(
host_ip="127.0.0.1", roku_usn="u",
advertise_ip="127.0.0.1", advertise_port=8060,
)
transport = MagicMock()
transport.is_closing.return_value = False
proto.connection_made(transport)
task = proto.notify_task
proto.close()
assert proto.notify_task is None
assert proto.transport is None
# Let cancellation propagate through the event loop
await asyncio.sleep(0)
assert task.cancelled()
transport.close.assert_called_once()
# ---------------------------------------------------------------------------
# 7. Utility functions
# ---------------------------------------------------------------------------
class TestUtilities:
def test_build_custom_apps_valid(self):
result = build_custom_apps("1:App One,2:App Two")
assert result is not None
assert "App One" in result
assert "App Two" in result
assert "" in result
def test_build_custom_apps_newline_separated(self):
result = build_custom_apps("1:App One\n2:App Two")
assert result is not None
assert "App One" in result
assert "App Two" in result
def test_build_custom_apps_invalid(self):
result = build_custom_apps("invalid_no_colon")
assert result is None
def test_build_custom_apps_mixed(self):
result = build_custom_apps("1:Valid,invalid,2:Also Valid")
assert result is not None
assert "Valid" in result
assert "Also Valid" in result
def test_get_local_ip_returns_string(self):
ip = get_local_ip()
assert isinstance(ip, str)
assert len(ip) > 0
def test_get_local_ip_fallback_to_hostname_resolution(self):
with (
patch("emulated_roku.socket.socket", side_effect=socket.error),
patch("emulated_roku.socket.gethostbyname", return_value="192.168.1.42"),
patch("emulated_roku.socket.gethostname", return_value="host"),
):
ip = get_local_ip()
assert ip == "192.168.1.42"
def test_get_local_ip_fallback_to_loopback(self):
with (
patch("emulated_roku.socket.socket", side_effect=socket.error),
patch("emulated_roku.socket.gethostbyname", side_effect=socket.gaierror),
):
ip = get_local_ip()
assert ip == "127.0.0.1"