added shitty roku integration

This commit is contained in:
ION606
2025-09-11 17:28:09 -04:00
parent 06790d74d9
commit f33571b8df
8 changed files with 349 additions and 0 deletions
+2
View File
@@ -130,3 +130,5 @@ dist
.yarn/install-state.gz
.pnp.*
__pycache__/
.venv/
+12
View File
@@ -11,6 +11,18 @@ services:
restart: always
depends_on:
- postgres
- tools
networks:
- internal
tools:
container_name: openwebui_tools
build:
context: ./tools
dockerfile: Dockerfile
env_file: .env
restart: unless-stopped
networks:
- internal
+1
View File
@@ -0,0 +1 @@
.venv/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.13.7-alpine3.22
WORKDIR /usr/app
COPY . .
ENV PYTHONUNBUFFERED=1
RUN pip install -r requirements.txt
CMD [ "python", "main.py" ]
+90
View File
@@ -0,0 +1,90 @@
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import urllib.parse as urlparse
import os
import json
import sys
import traceback
from pathlib import Path
from rokuHandler import RokuWrapper, ROKU_IP
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "1331"))
ROOT = Path(__file__).parent.resolve()
print("root at", ROOT)
class HTTPHandler(BaseHTTPRequestHandler):
def setup(self):
print("setting up...")
super().setup()
self.rapp = RokuWrapper(self)
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
def _send(self, code=200, body=b''):
self.send_response(code)
self._cors()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def _send_file(self, path: Path, content_type: str):
if not path.exists() or not path.is_file():
self._send(404, json.dumps({"error": "file not found"}).encode())
return
# send file content as JSON payload (openapi/spec are JSON)
data = path.read_bytes()
self._send(200, data)
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
parsed = urlparse.urlparse(self.path)
path = parsed.path.rstrip("/").lower()
try:
# specs
if path == "/roku/openapi.json":
return self._send_file(ROOT / "spec" / "roku.openapi.json", "application/json")
if path.startswith("/roku"):
return self.rapp.run_command(path.replace("/roku/", ''))
# catch-all
self._send(404, json.dumps({"error": "unknown endpoint"}).encode())
except Exception as ex:
err = {"error": str(ex)}
self.log_error("Handler error: %s\n%s", ex, traceback.format_exc())
self._send(500, json.dumps(err).encode())
def log_message(self, format, *args):
# keep logs short and to stderr (default)
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0],
self.log_date_time_string(), format % args))
def run(host=HOST, port=PORT):
server = ThreadingHTTPServer((host, port), HTTPHandler)
try:
print(f"Listening on {host}:{port} - Roku at {ROKU_IP}")
server.serve_forever()
print('finished running')
except KeyboardInterrupt:
print("Shutting down")
server.server_close()
if __name__ == "__main__":
run()
else:
print("Not running because this is not main")
+6
View File
@@ -0,0 +1,6 @@
certifi==2025.8.3
charset-normalizer==3.4.3
idna==3.10
requests==2.32.5
roku==4.1.0
urllib3==2.5.0
+91
View File
@@ -0,0 +1,91 @@
from roku import Roku
import os
import json
import threading
ROKU_IP = os.environ.get("ROKU_IP")
if not ROKU_IP:
raise Exception("Roku IP not found in env!")
COMMANDS = {
# Standard Keys
"home": "Home",
"reverse": "Rev",
"forward": "Fwd",
"play": "Play",
"select": "Select",
"left": "Left",
"right": "Right",
"down": "Down",
"up": "Up",
"back": "Back",
"replay": "InstantReplay",
"info": "Info",
"backspace": "Backspace",
"search": "Search",
"enter": "Enter",
"literal": "Lit",
# For devices that support "Find Remote"
"find_remote": "FindRemote",
# For Roku TV
"volume_down": "VolumeDown",
"volume_up": "VolumeUp",
"volume_mute": "VolumeMute",
# For Roku TV while on TV tuner channel
"channel_up": "ChannelUp",
"channel_down": "ChannelDown",
# For Roku TV current input
"input_tuner": "InputTuner",
"input_hdmi1": "InputHDMI1",
"input_hdmi2": "InputHDMI2",
"input_hdmi3": "InputHDMI3",
"input_hdmi4": "InputHDMI4",
"input_av1": "InputAV1",
# For devices that support being turned on/off
"power": "Power",
"poweroff": "PowerOff",
"poweron": "PowerOn",
}
class RokuWrapper():
def __init__(self, parent):
self.parent = parent
# eh maybe close the connection? Not really needed
self.timer: threading.Timer | None = None
try:
self.rapp = Roku(ROKU_IP)
print(f"Roku client connected to {ROKU_IP}", flush=True)
except Exception as e:
self.parent.log_error("Failed to create Roku client: %s", e)
return self.parent._send(500, json.dumps({"error": "roku connection failed"}).encode())
def run_command(self, command: str):
if not command:
return self.parent._send(404, json.dumps({"error": "command not given"}).encode())
command = command.split('/')[0]
# If command is not known, nothing to do
if command not in COMMANDS:
return self.parent._send(404, json.dumps({"error": "unknown command"}).encode())
if hasattr(self.rapp, command):
attr = getattr(self.rapp, command)
if callable(attr):
attr()
return {"result": f"{command} executed"}
# Fallback: send the Roku key name using common API methods
key_name = COMMANDS[command]
# common method names in Roku libs
for send_fn in ("press", "keypress", "send_key", "send_keypress", "button"):
if hasattr(self.rapp, send_fn):
fn = getattr(self.rapp, send_fn)
try:
fn(key_name)
return {"result": f"{command} sent as {key_name} via {send_fn}"}
except TypeError:
continue
self.parent._send(404, json.dumps({"error": "unknown endpoint"}).encode())
+136
View File
@@ -0,0 +1,136 @@
{
"openapi": "3.1.0",
"info": {
"title": "Roku Remote HTTP API",
"description": "tiny http wrapper around the `roku` python library",
"version": "1.0.0"
},
"servers": [
{
"url": "http://{host}:{port}",
"variables": {
"host": {
"default": "0.0.0.0"
},
"port": {
"default": "8000"
}
}
}
],
"paths": {
"/{command}": {
"get": {
"operationId": "sendRokuCommand",
"summary": "send a command to the configured roku",
"parameters": [
{
"name": "command",
"in": "path",
"required": true,
"description": "one of the supported roku commands.",
"schema": {
"$ref": "#/components/schemas/RokuCommand"
}
}
],
"responses": {
"200": {
"description": "command accepted and sent",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
}
}
}
},
"404": {
"description": "unknown endpoint or unsupported command",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "unhandled server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Result": {
"type": "object",
"additionalProperties": false,
"properties": {
"result": {
"type": "string"
}
},
"required": [
"result"
]
},
"Error": {
"type": "object",
"additionalProperties": false,
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
},
"RokuCommand": {
"type": "string",
"enum": [
"home",
"reverse",
"forward",
"play",
"select",
"left",
"right",
"down",
"up",
"back",
"replay",
"info",
"backspace",
"search",
"enter",
"literal",
"find_remote",
"volume_down",
"volume_up",
"volume_mute",
"channel_up",
"channel_down",
"input_tuner",
"input_hdmi1",
"input_hdmi2",
"input_hdmi3",
"input_hdmi4",
"input_av1",
"power",
"poweroff",
"poweron"
]
}
}
}
}